Initial Commit
This commit is contained in:
109
packages/api-stack/api/api-plugin-notifications/README.md
Normal file
109
packages/api-stack/api/api-plugin-notifications/README.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# @suaveplan/api-plugin-notifications - Notifications plugin for @suaveplan/api. Ships SurrealDB-backed NotificationRepository + DeviceTokenRepository, /notifications and /push Hono OpenAPI route groups, an APN service with injectable transport (no hardcoded network library), an event-bus integration via ctx.eventBus, 18 NOTIFICATIONS_PLUGIN_* error codes, and 6 OTel counters. Server-tier — runs in Bun and Node.
|
||||
|
||||
SurrealDB-backed notifications + push plugin for `@suaveplan/api`.
|
||||
Ships a `NotificationRepository` + `DeviceTokenRepository` pair, an
|
||||
`ApnService` with a pluggable transport contract (no hardcoded
|
||||
`@parse/node-apn` dependency), eleven `/notifications/*` Hono OpenAPI
|
||||
routes, five `/push/*` routes, an opt-in multi-channel
|
||||
`NotificationDispatcher` (`apn` + `email`) wired on top of
|
||||
`@suaveplan/notification-channel`, `@suaveplan/emailer`, and
|
||||
`@suaveplan/delivery-tracking`, twenty-two `NOTIFICATIONS_PLUGIN_*`
|
||||
error codes, and eight OTel counters.
|
||||
|
||||
Server-tier — runs in Bun and Node.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [apn-channel](./src/channels/apn-channel/apn-channel.md) — Adapts the in-package `ApnService` to the `@suaveplan/notification-channel` `DeliveryChannel` interface so `Notificatio…
|
||||
- [apn-channel](./src/channels/apn-channel/apn-channel.md) — Adapts the in-package `ApnService` to the `@suaveplan/notification-channel` `DeliveryChannel` interface so `Notificatio…
|
||||
- [email-channel](./src/channels/email-channel/email-channel.md) — Adapts `@suaveplan/emailer`'s `EmailService` to the `@suaveplan/notification-channel` `DeliveryChannel` interface so `N…
|
||||
- [email-channel](./src/channels/email-channel/email-channel.md) — Adapts `@suaveplan/emailer`'s `EmailService` to the `@suaveplan/notification-channel` `DeliveryChannel` interface so `N…
|
||||
- [idempotency-adapter](./src/dispatcher/idempotency-adapter/idempotency-adapter.md) — Defines the narrow contract that lets `NotificationDispatcher` delegate per-call deduplication to a caller-supplied imp…
|
||||
- [idempotency-adapter](./src/dispatcher/idempotency-adapter/idempotency-adapter.md) — Defines the narrow contract that lets `NotificationDispatcher` delegate per-call deduplication to a caller-supplied imp…
|
||||
- [notification-dispatcher](./src/dispatcher/notification-dispatcher/notification-dispatcher.md) — Top-level orchestrator that lets callers send one notification through whichever delivery channel they pick (APN, email…
|
||||
- [notification-dispatcher](./src/dispatcher/notification-dispatcher/notification-dispatcher.md) — Top-level orchestrator that lets callers send one notification through whichever delivery channel they pick (APN, email…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/api-plugin-notifications`. The module exports a frozen `NOTIFICATIONS_PLUGIN…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/api-plugin-notifications`. The module exports a frozen `NOTIFICATIONS_PLUGIN…
|
||||
- [otel](./src/otel/instruments.md) — OpenTelemetry instrument bundle for `@suaveplan/api-plugin-notifications`. The module exposes a lazy, memoised factory…
|
||||
- [instruments](./src/otel/instruments.md) — OpenTelemetry instrument bundle for `@suaveplan/api-plugin-notifications`. The module exposes a lazy, memoised factory…
|
||||
- [plugin](./src/plugin/plugin.md) — Plugin descriptor factory for `@suaveplan/api-plugin-notifications`. Composes everything in the package — Zod-validated…
|
||||
- [plugin](./src/plugin/plugin.md) — Plugin descriptor factory for `@suaveplan/api-plugin-notifications`. Composes everything in the package — Zod-validated…
|
||||
- [device-token-repository](./src/repositories/device-token-repository/device-token-repository.md) — SurrealDB-backed repository for the `device_token` table. Owns every SurrealQL statement the plugin issues against devi…
|
||||
- [device-token-repository](./src/repositories/device-token-repository/device-token-repository.md) — SurrealDB-backed repository for the `device_token` table. Owns every SurrealQL statement the plugin issues against devi…
|
||||
- [notification-repository](./src/repositories/notification-repository/notification-repository.md) — SurrealDB-backed repository for the `notification` table. Owns every SurrealQL statement the plugin issues against noti…
|
||||
- [notification-repository](./src/repositories/notification-repository/notification-repository.md) — SurrealDB-backed repository for the `notification` table. Owns every SurrealQL statement the plugin issues against noti…
|
||||
- [notifications-routes](./src/routes/notifications-routes/notifications-routes.md) — Hono OpenAPI route module exposing the `/notifications/*` surface. Eleven endpoints cover list / unread-count / create…
|
||||
- [notifications-routes](./src/routes/notifications-routes/notifications-routes.md) — Hono OpenAPI route module exposing the `/notifications/*` surface. Eleven endpoints cover list / unread-count / create…
|
||||
- [push-routes](./src/routes/push-routes/push-routes.md) — Hono OpenAPI route module exposing the `/push/*` surface for device- token registration and APN delivery. Five endpoint…
|
||||
- [push-routes](./src/routes/push-routes/push-routes.md) — Hono OpenAPI route module exposing the `/push/*` surface for device- token registration and APN delivery. Five endpoint…
|
||||
- [dispatch-schemas](./src/schemas/dispatch-schemas.md) — Defines the Zod boundary for the `NotificationDispatcher` surface. The dispatcher fans a single call out to whichever d…
|
||||
- [notification-schemas](./src/schemas/notification-schemas.md) — Zod boundary schemas for the notification entity and every wire surface the plugin exposes — list / get / create / upda…
|
||||
- [options](./src/schemas/options.md) — Zod schema for plugin options accepted by `createNotificationsPlugin()` plus the nested APN configuration shape (`ApnCo…
|
||||
- [push-schemas](./src/schemas/push-schemas.md) — Zod boundary schemas for the `/push/*` route group and the device-token entity stored in SurrealDB. Schemas describe bo…
|
||||
- [services](./src/services/registry-augmentation.md) — Module-augmentation file that declares three new service slots on `@suaveplan/api-plugin/service-registry`'s `ServiceRe…
|
||||
- [apn-service](./src/services/apn-service/apn-service.md) — Apple Push Notification delivery service. Validates an `ApnConfig` at construction-time, builds a pluggable `ApnTranspo…
|
||||
- [apn-service](./src/services/apn-service/apn-service.md) — Apple Push Notification delivery service. Validates an `ApnConfig` at construction-time, builds a pluggable `ApnTranspo…
|
||||
- [registry-augmentation](./src/services/registry-augmentation.md) — Module-augmentation file that declares three new service slots on `@suaveplan/api-plugin/service-registry`'s `ServiceRe…
|
||||
- [apn-transport](./src/transport/apn-transport/apn-transport.md) — Pluggable transport contract for Apple Push Notification delivery. The plugin never depends on a concrete APN client li…
|
||||
- [apn-transport](./src/transport/apn-transport/apn-transport.md) — Pluggable transport contract for Apple Push Notification delivery. The plugin never depends on a concrete APN client li…
|
||||
- [types](./src/types/types.md) — Public type aliases for cross-module wiring: the typed event-bus payload map (`NotificationsPluginEvents`), the discrim…
|
||||
- [types](./src/types/types.md) — Public type aliases for cross-module wiring: the typed event-bus payload map (`NotificationsPluginEvents`), the discrim…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/error-codes/error-codes.md`](src/error-codes/error-codes.md)
|
||||
- [`src/otel/instruments.md`](src/otel/instruments.md)
|
||||
- [`src/plugin/plugin.md`](src/plugin/plugin.md)
|
||||
- [`src/repositories/notification-repository/notification-repository.md`](src/repositories/notification-repository/notification-repository.md)
|
||||
- [`src/repositories/device-token-repository/device-token-repository.md`](src/repositories/device-token-repository/device-token-repository.md)
|
||||
- [`src/routes/notifications-routes/notifications-routes.md`](src/routes/notifications-routes/notifications-routes.md)
|
||||
- [`src/routes/push-routes/push-routes.md`](src/routes/push-routes/push-routes.md)
|
||||
- [`src/schemas/options.md`](src/schemas/options.md)
|
||||
- [`src/schemas/notification-schemas.md`](src/schemas/notification-schemas.md)
|
||||
- [`src/schemas/push-schemas.md`](src/schemas/push-schemas.md)
|
||||
- [`src/services/apn-service/apn-service.md`](src/services/apn-service/apn-service.md)
|
||||
- [`src/services/registry-augmentation.md`](src/services/registry-augmentation.md)
|
||||
- [`src/transport/apn-transport/apn-transport.md`](src/transport/apn-transport/apn-transport.md)
|
||||
- [`src/types/types.md`](src/types/types.md)
|
||||
- [`src/channels/apn-channel/apn-channel.md`](src/channels/apn-channel/apn-channel.md)
|
||||
- [`src/channels/email-channel/email-channel.md`](src/channels/email-channel/email-channel.md)
|
||||
- [`src/dispatcher/notification-dispatcher/notification-dispatcher.md`](src/dispatcher/notification-dispatcher/notification-dispatcher.md)
|
||||
- [`src/dispatcher/idempotency-adapter/idempotency-adapter.md`](src/dispatcher/idempotency-adapter/idempotency-adapter.md)
|
||||
- [`src/schemas/dispatch-schemas.md`](src/schemas/dispatch-schemas.md)
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { createNotificationsPlugin } from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
await api.loadPlugin(
|
||||
createNotificationsPlugin({
|
||||
basePath: "/v1/notifications",
|
||||
pushBasePath: "/v1/push",
|
||||
enablePush: true,
|
||||
apn: {
|
||||
token: { key: "/keys/AuthKey.p8", keyId: "K", teamId: "T" },
|
||||
defaultTopic: "com.example.app",
|
||||
production: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## Pluggable APN transport
|
||||
|
||||
Production hosts inject a `node-apn`-backed `ApnTransport`; tests use
|
||||
the deterministic `MemoryApnTransport`:
|
||||
|
||||
```ts
|
||||
import { ApnService, type ApnTransport } from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
const transport: ApnTransport = makeNodeApnTransport(provider);
|
||||
const service = new ApnService({ config, transport });
|
||||
```
|
||||
|
||||
See `src/transport/apn-transport/apn-transport.md` for the full
|
||||
contract.
|
||||
|
||||
179
packages/api-stack/api/api-plugin-notifications/package.json
Normal file
179
packages/api-stack/api/api-plugin-notifications/package.json
Normal file
@@ -0,0 +1,179 @@
|
||||
{
|
||||
"name": "@suaveplan/api-plugin-notifications",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Notifications plugin for @suaveplan/api. Ships SurrealDB-backed NotificationRepository + DeviceTokenRepository, /notifications and /push Hono OpenAPI route groups, an APN service with injectable transport (no hardcoded network library), an event-bus integration via ctx.eventBus, 18 NOTIFICATIONS_PLUGIN_* error codes, and 6 OTel counters. Server-tier — runs in Bun and Node.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./plugin/plugin": {
|
||||
"import": {
|
||||
"types": "./dist/plugin/plugin.d.ts",
|
||||
"default": "./dist/plugin/plugin.js"
|
||||
}
|
||||
},
|
||||
"./schemas/options": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/options.d.ts",
|
||||
"default": "./dist/schemas/options.js"
|
||||
}
|
||||
},
|
||||
"./schemas/notification-schemas": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/notification-schemas.d.ts",
|
||||
"default": "./dist/schemas/notification-schemas.js"
|
||||
}
|
||||
},
|
||||
"./schemas/push-schemas": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/push-schemas.d.ts",
|
||||
"default": "./dist/schemas/push-schemas.js"
|
||||
}
|
||||
},
|
||||
"./error-codes/error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./otel/instruments": {
|
||||
"import": {
|
||||
"types": "./dist/otel/instruments.d.ts",
|
||||
"default": "./dist/otel/instruments.js"
|
||||
}
|
||||
},
|
||||
"./repositories/notification-repository/notification-repository": {
|
||||
"import": {
|
||||
"types": "./dist/repositories/notification-repository/notification-repository.d.ts",
|
||||
"default": "./dist/repositories/notification-repository/notification-repository.js"
|
||||
}
|
||||
},
|
||||
"./repositories/device-token-repository/device-token-repository": {
|
||||
"import": {
|
||||
"types": "./dist/repositories/device-token-repository/device-token-repository.d.ts",
|
||||
"default": "./dist/repositories/device-token-repository/device-token-repository.js"
|
||||
}
|
||||
},
|
||||
"./routes/notifications-routes/notifications-routes": {
|
||||
"import": {
|
||||
"types": "./dist/routes/notifications-routes/notifications-routes.d.ts",
|
||||
"default": "./dist/routes/notifications-routes/notifications-routes.js"
|
||||
}
|
||||
},
|
||||
"./routes/push-routes/push-routes": {
|
||||
"import": {
|
||||
"types": "./dist/routes/push-routes/push-routes.d.ts",
|
||||
"default": "./dist/routes/push-routes/push-routes.js"
|
||||
}
|
||||
},
|
||||
"./services/apn-service/apn-service": {
|
||||
"import": {
|
||||
"types": "./dist/services/apn-service/apn-service.d.ts",
|
||||
"default": "./dist/services/apn-service/apn-service.js"
|
||||
}
|
||||
},
|
||||
"./transport/apn-transport/apn-transport": {
|
||||
"import": {
|
||||
"types": "./dist/transport/apn-transport/apn-transport.d.ts",
|
||||
"default": "./dist/transport/apn-transport/apn-transport.js"
|
||||
}
|
||||
},
|
||||
"./types/types": {
|
||||
"import": {
|
||||
"types": "./dist/types/types.d.ts",
|
||||
"default": "./dist/types/types.js"
|
||||
}
|
||||
},
|
||||
"./dispatcher/notification-dispatcher/notification-dispatcher": {
|
||||
"import": {
|
||||
"types": "./dist/dispatcher/notification-dispatcher/notification-dispatcher.d.ts",
|
||||
"default": "./dist/dispatcher/notification-dispatcher/notification-dispatcher.js"
|
||||
}
|
||||
},
|
||||
"./dispatcher/idempotency-adapter/idempotency-adapter": {
|
||||
"import": {
|
||||
"types": "./dist/dispatcher/idempotency-adapter/idempotency-adapter.d.ts",
|
||||
"default": "./dist/dispatcher/idempotency-adapter/idempotency-adapter.js"
|
||||
}
|
||||
},
|
||||
"./channels/apn-channel/apn-channel": {
|
||||
"import": {
|
||||
"types": "./dist/channels/apn-channel/apn-channel.d.ts",
|
||||
"default": "./dist/channels/apn-channel/apn-channel.js"
|
||||
}
|
||||
},
|
||||
"./channels/email-channel/email-channel": {
|
||||
"import": {
|
||||
"types": "./dist/channels/email-channel/email-channel.d.ts",
|
||||
"default": "./dist/channels/email-channel/email-channel.js"
|
||||
}
|
||||
},
|
||||
"./schemas/dispatch-schemas": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/dispatch-schemas.d.ts",
|
||||
"default": "./dist/schemas/dispatch-schemas.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/zod-openapi": "catalog:server",
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/api": "workspace:^",
|
||||
"@suaveplan/api-plugin": "workspace:^",
|
||||
"@suaveplan/api-plugin-database": "workspace:^",
|
||||
"@suaveplan/delivery-tracking": "workspace:^",
|
||||
"@suaveplan/emailer": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/notification-channel": "workspace:^",
|
||||
"@suaveplan/surrealdb": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"hono": "catalog:",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@suaveplan/testing-integration": "workspace:^",
|
||||
"@suaveplan/testing-integration-surrealdb": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "surql", "README.md"]
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import "./services/registry-augmentation.js";
|
||||
import {
|
||||
ApnConfigSchema,
|
||||
ApnDeliveryChannel,
|
||||
ApnService,
|
||||
CreateNotificationSchema,
|
||||
createApnService,
|
||||
createMemoryApnTransport,
|
||||
createNotificationDispatcher,
|
||||
createNotificationsPlugin,
|
||||
createNotificationsRoutes,
|
||||
createPushRoutes,
|
||||
DeviceTokenIdParamSchema,
|
||||
DeviceTokenRepository,
|
||||
DeviceTokenSchema,
|
||||
type DispatcherIdempotencyAdapter,
|
||||
DispatchInputSchema,
|
||||
DispatchResultSchema,
|
||||
EmailDeliveryChannel,
|
||||
getNotificationsInstruments,
|
||||
type IDeviceTokenRepository,
|
||||
type INotificationRepository,
|
||||
MarkManyAsReadSchema,
|
||||
MemoryApnTransport,
|
||||
NOTIFICATIONS_PLUGIN_CODES,
|
||||
NoopIdempotencyAdapter,
|
||||
NotificationDispatcher,
|
||||
NotificationIdParamSchema,
|
||||
NotificationListResponseSchema,
|
||||
NotificationQuerySchema,
|
||||
NotificationRepository,
|
||||
NotificationSchema,
|
||||
type NotificationsInstruments,
|
||||
type NotificationsPluginConfigInput,
|
||||
NotificationsPluginError,
|
||||
type NotificationsPluginErrorCode,
|
||||
NotificationsPluginOptionsSchema,
|
||||
type NotificationsPluginServices,
|
||||
type NotificationsRouteEvent,
|
||||
type NotificationsRoutesVariables,
|
||||
NotificationTypeSchema,
|
||||
type PushRoutesVariables,
|
||||
RegisterDeviceTokenSchema,
|
||||
resetNotificationsInstruments,
|
||||
SendBulkPushNotificationSchema,
|
||||
SendPushNotificationSchema,
|
||||
TargetTypeSchema,
|
||||
UpdateNotificationSchema,
|
||||
} from "./index.js";
|
||||
|
||||
describe("public surface", () => {
|
||||
it("re-exports every documented symbol", { timeout: 5000 }, () => {
|
||||
const surface = {
|
||||
createNotificationsPlugin,
|
||||
createNotificationsRoutes,
|
||||
createPushRoutes,
|
||||
NotificationRepository,
|
||||
DeviceTokenRepository,
|
||||
ApnService,
|
||||
createApnService,
|
||||
MemoryApnTransport,
|
||||
createMemoryApnTransport,
|
||||
NotificationsPluginError,
|
||||
NOTIFICATIONS_PLUGIN_CODES,
|
||||
getNotificationsInstruments,
|
||||
resetNotificationsInstruments,
|
||||
NotificationsPluginOptionsSchema,
|
||||
ApnConfigSchema,
|
||||
NotificationSchema,
|
||||
CreateNotificationSchema,
|
||||
UpdateNotificationSchema,
|
||||
NotificationQuerySchema,
|
||||
NotificationListResponseSchema,
|
||||
NotificationIdParamSchema,
|
||||
MarkManyAsReadSchema,
|
||||
NotificationTypeSchema,
|
||||
TargetTypeSchema,
|
||||
DeviceTokenSchema,
|
||||
RegisterDeviceTokenSchema,
|
||||
DeviceTokenIdParamSchema,
|
||||
SendPushNotificationSchema,
|
||||
SendBulkPushNotificationSchema,
|
||||
ApnDeliveryChannel,
|
||||
EmailDeliveryChannel,
|
||||
NotificationDispatcher,
|
||||
createNotificationDispatcher,
|
||||
NoopIdempotencyAdapter,
|
||||
DispatchInputSchema,
|
||||
DispatchResultSchema,
|
||||
};
|
||||
for (const [name, value] of Object.entries(surface)) {
|
||||
if (value === undefined) {
|
||||
throw new Error(
|
||||
`symbol ${name} is undefined in the package root`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("NotificationsPluginErrorCode narrows to the registry value type", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expectTypeOf<NotificationsPluginErrorCode>().toEqualTypeOf<
|
||||
(typeof NOTIFICATIONS_PLUGIN_CODES)[keyof typeof NOTIFICATIONS_PLUGIN_CODES]
|
||||
>();
|
||||
});
|
||||
|
||||
it("NotificationsInstruments exposes six counters", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expectTypeOf<NotificationsInstruments>().toHaveProperty(
|
||||
"notificationsCreatedTotal"
|
||||
);
|
||||
expectTypeOf<NotificationsInstruments>().toHaveProperty(
|
||||
"notificationsReadTotal"
|
||||
);
|
||||
expectTypeOf<NotificationsInstruments>().toHaveProperty(
|
||||
"notificationsDeletedTotal"
|
||||
);
|
||||
expectTypeOf<NotificationsInstruments>().toHaveProperty(
|
||||
"pushSentTotal"
|
||||
);
|
||||
expectTypeOf<NotificationsInstruments>().toHaveProperty(
|
||||
"pushFailedTotal"
|
||||
);
|
||||
expectTypeOf<NotificationsInstruments>().toHaveProperty(
|
||||
"deviceTokensRegisteredTotal"
|
||||
);
|
||||
});
|
||||
|
||||
it("NotificationsInstruments exposes dispatcher counters", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expectTypeOf<NotificationsInstruments>().toHaveProperty(
|
||||
"notificationsDispatchedTotal"
|
||||
);
|
||||
expectTypeOf<NotificationsInstruments>().toHaveProperty(
|
||||
"notificationsDispatchFailedTotal"
|
||||
);
|
||||
});
|
||||
|
||||
it("NotificationsPluginServices exposes injectable bag", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s: NotificationsPluginServices = {};
|
||||
expect(s).toBeDefined();
|
||||
});
|
||||
|
||||
it("DispatcherIdempotencyAdapter exposes execute", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expectTypeOf<DispatcherIdempotencyAdapter>().toHaveProperty("execute");
|
||||
});
|
||||
|
||||
it("NotificationsPluginConfigInput allows partial config", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const input: NotificationsPluginConfigInput = {};
|
||||
expect(input).toBeDefined();
|
||||
});
|
||||
|
||||
it("NotificationsRouteEvent is a discriminated union", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const event: NotificationsRouteEvent = {
|
||||
type: "notification:created",
|
||||
payload: {
|
||||
notificationId: "notification:1",
|
||||
recipientId: "user:1",
|
||||
type: "friend_request",
|
||||
},
|
||||
};
|
||||
expect(event.type).toBe("notification:created");
|
||||
});
|
||||
|
||||
it("NotificationsRoutesVariables carries user + siteId", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expectTypeOf<NotificationsRoutesVariables>().toHaveProperty("user");
|
||||
});
|
||||
|
||||
it("PushRoutesVariables carries user", { timeout: 5000 }, () => {
|
||||
expectTypeOf<PushRoutesVariables>().toHaveProperty("user");
|
||||
});
|
||||
});
|
||||
|
||||
describe("service registry augmentation", () => {
|
||||
it("ServiceRegistry exposes notification / device / apn slots", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const lookups: Array<
|
||||
"notificationRepository" | "deviceTokenRepository" | "apnService"
|
||||
> = ["notificationRepository", "deviceTokenRepository", "apnService"];
|
||||
expect(lookups).toHaveLength(3);
|
||||
const _extended: Array<"notificationDispatcher" | "emailService"> = [
|
||||
"notificationDispatcher",
|
||||
"emailService",
|
||||
];
|
||||
expect(_extended).toHaveLength(2);
|
||||
const repoFake: INotificationRepository = {
|
||||
create: async () => ({
|
||||
id: "notification:1",
|
||||
type: "system",
|
||||
recipient: "user:1",
|
||||
content: "",
|
||||
read: false,
|
||||
dismissed: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
}),
|
||||
findById: async () => null,
|
||||
findByRecipient: async () => [],
|
||||
getUnreadCount: async () => 0,
|
||||
update: async () => null,
|
||||
markAsRead: async () => null,
|
||||
markManyAsRead: async () => ({ count: 0, updated: 0 }),
|
||||
markAllAsRead: async () => ({ count: 0, updated: 0 }),
|
||||
dismiss: async () => null,
|
||||
delete: async () => true,
|
||||
findGroupedByType: async () => ({}),
|
||||
deleteOldRead: async () => 0,
|
||||
};
|
||||
const tokFake: IDeviceTokenRepository = {
|
||||
register: async () => ({
|
||||
id: "device_token:1",
|
||||
user_id: "user:1",
|
||||
device_id: "d",
|
||||
token: "t",
|
||||
platform: "ios",
|
||||
environment: "sandbox",
|
||||
is_active: true,
|
||||
last_used: "",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
}),
|
||||
findById: async () => null,
|
||||
findByToken: async () => null,
|
||||
findActiveForUser: async () => [],
|
||||
deactivate: async () => true,
|
||||
deactivateByToken: async () => true,
|
||||
touchLastUsed: async () => undefined,
|
||||
cleanup: async () => 0,
|
||||
};
|
||||
expect(repoFake).toBeDefined();
|
||||
expect(tokFake).toBeDefined();
|
||||
});
|
||||
|
||||
it("registry slots include dispatcher and emailService", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const slots: Array<"notificationDispatcher" | "emailService"> = [
|
||||
"notificationDispatcher",
|
||||
"emailService",
|
||||
];
|
||||
expect(slots).toEqual(["notificationDispatcher", "emailService"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
# apn-channel
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Adapts the in-package `ApnService` to the
|
||||
`@suaveplan/notification-channel` `DeliveryChannel` interface so
|
||||
`NotificationDispatcher` can fan out to APN alongside email and
|
||||
webhook channels. The channel is registered under the name `apn` and
|
||||
canSend-gates on `notification.channel === "push"` so push routing
|
||||
rules in the upstream router can target it explicitly. This module is
|
||||
the single place where the dispatcher's generic `Notification` shape
|
||||
is translated into the APN-specific `PushNotificationPayload` —
|
||||
keeping that translation out of the router and out of `ApnService`
|
||||
itself.
|
||||
|
||||
## Features
|
||||
|
||||
- Channel name `apn`; canSend gates on `notification.channel ===
|
||||
"push"`.
|
||||
- Reads the device token from `Notification.metadata.deviceToken`
|
||||
(string) for single-device sends and
|
||||
`Notification.metadata.deviceTokens` (string array) for fan-out.
|
||||
- Maps `Notification.subject` → APN alert title, `Notification.body`
|
||||
→ APN alert body.
|
||||
- Honours per-notification `metadata.topic`, `metadata.badge`,
|
||||
`metadata.sound`, `metadata.category` overrides; falls back to the
|
||||
channel-level `defaultTopic` when no per-notification topic is set.
|
||||
- Folds per-device failures into a single `DeliveryResult` whose
|
||||
`success` reflects whether at least one delivery succeeded. The
|
||||
first failure's `error` is surfaced as `DeliveryResult.error`.
|
||||
- Returns `{ success: false, error: "deviceToken metadata required" }`
|
||||
when neither `deviceToken` nor `deviceTokens` is present — no
|
||||
throws.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
ApnDeliveryChannel,
|
||||
ApnService,
|
||||
ApnConfigSchema,
|
||||
} from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
const apn = new ApnService({
|
||||
config: ApnConfigSchema.parse({
|
||||
token: { key: "k", keyId: "i", teamId: "t" },
|
||||
defaultTopic: "com.example.app",
|
||||
}),
|
||||
});
|
||||
await apn.initialize();
|
||||
|
||||
const channel = new ApnDeliveryChannel({ apnService: apn });
|
||||
|
||||
const result = await channel.send({
|
||||
id: "notif_1",
|
||||
channel: "push",
|
||||
recipient: "user-42",
|
||||
body: "You have a new message",
|
||||
priority: "high",
|
||||
status: "sent",
|
||||
metadata: { deviceToken: "abc123" },
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
// result.success === true (when the in-memory transport accepts)
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Fan a single notification out to a bag of device tokens with custom
|
||||
sound and badge override, then plug into a router:
|
||||
|
||||
```ts
|
||||
import { NotificationRouter } from "@suaveplan/notification-channel";
|
||||
|
||||
const router = new NotificationRouter({
|
||||
channels: [{ channel: "push", enabled: true }],
|
||||
defaultChannel: "push",
|
||||
deliveryChannels: [new ApnDeliveryChannel({
|
||||
apnService: apn,
|
||||
defaultTopic: "com.example.app",
|
||||
})],
|
||||
});
|
||||
|
||||
await router.send({
|
||||
recipient: "user-42",
|
||||
channel: "push",
|
||||
body: "Order shipped",
|
||||
metadata: {
|
||||
deviceTokens: ["tokenA", "tokenB", "tokenC"],
|
||||
badge: 3,
|
||||
sound: "ping.caf",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class ApnDeliveryChannel implements DeliveryChannel`
|
||||
- `name: "apn"`
|
||||
- `canSend(n): boolean` — true iff `n.channel === "push"`
|
||||
- `send(n): Promise<DeliveryResult>`
|
||||
- `interface ApnDeliveryChannelOptions`
|
||||
- `apnService: ApnService` (required)
|
||||
- `defaultTopic?: string`
|
||||
- `clock?: Clock`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The channel never throws on per-device failures — it folds them
|
||||
into the `DeliveryResult.error` field so the upstream
|
||||
`NotificationRouter` can decide whether to retry / dead-letter.
|
||||
- Bulk send path uses `ApnService.sendBulkNotifications` which fans
|
||||
out at the transport layer; the channel layer does not slice the
|
||||
array further.
|
||||
- Single-token path uses `ApnService.sendNotification` to preserve
|
||||
per-token result granularity in the existing OTel counters.
|
||||
- Metadata is intentionally read defensively (`typeof === "string"` /
|
||||
`Array.isArray` guards) because the router's `Notification.metadata`
|
||||
type is `Record<string, unknown>`.
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { Notification } from "@suaveplan/notification-channel";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { ApnConfigSchema } from "../../schemas/options.js";
|
||||
import { ApnService } from "../../services/apn-service/apn-service.js";
|
||||
import {
|
||||
createMemoryApnTransport,
|
||||
MemoryApnTransport,
|
||||
} from "../../transport/apn-transport/apn-transport.js";
|
||||
import { ApnDeliveryChannel } from "./apn-channel.js";
|
||||
|
||||
function makeService(failurePlan?: Map<string, { reason: string }>) {
|
||||
const config = ApnConfigSchema.parse({
|
||||
token: { key: "k", keyId: "i", teamId: "t" },
|
||||
defaultTopic: "com.example.app",
|
||||
});
|
||||
const transport =
|
||||
failurePlan === undefined
|
||||
? createMemoryApnTransport(config)
|
||||
: new MemoryApnTransport(config, failurePlan);
|
||||
const service = new ApnService({ config, transport });
|
||||
return service;
|
||||
}
|
||||
|
||||
function baseNotification(overrides: Partial<Notification>): Notification {
|
||||
return {
|
||||
id: "notif_1",
|
||||
channel: "push",
|
||||
recipient: "user-1",
|
||||
body: "hello",
|
||||
priority: "normal",
|
||||
status: "sent",
|
||||
createdAt: "2026-05-19T00:00:00.000Z",
|
||||
...overrides,
|
||||
} as Notification;
|
||||
}
|
||||
|
||||
describe("ApnDeliveryChannel", () => {
|
||||
it("sends through ApnService for a single deviceToken", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
await service.initialize();
|
||||
const channel = new ApnDeliveryChannel({ apnService: service });
|
||||
const result = await channel.send(
|
||||
baseNotification({
|
||||
metadata: { deviceToken: "tok1" },
|
||||
subject: "Hello!",
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.channel).toBe("apn");
|
||||
expect(result.messageId).toBe("notif_1");
|
||||
});
|
||||
|
||||
it("fans out to multiple deviceTokens via metadata.deviceTokens", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
await service.initialize();
|
||||
const channel = new ApnDeliveryChannel({ apnService: service });
|
||||
const result = await channel.send(
|
||||
baseNotification({
|
||||
metadata: { deviceTokens: ["a", "b", "c"] },
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("returns success false when deviceToken metadata is missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
await service.initialize();
|
||||
const channel = new ApnDeliveryChannel({ apnService: service });
|
||||
const result = await channel.send(baseNotification({}));
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("deviceToken metadata required");
|
||||
});
|
||||
|
||||
it("folds APN per-device failures into the DeliveryResult", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService(
|
||||
new Map([["bad", { reason: "Unregistered" }]])
|
||||
);
|
||||
await service.initialize();
|
||||
const channel = new ApnDeliveryChannel({ apnService: service });
|
||||
const result = await channel.send(
|
||||
baseNotification({
|
||||
metadata: { deviceToken: "bad" },
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("Unregistered");
|
||||
});
|
||||
|
||||
it("reports canSend true only for push channel notifications", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
const channel = new ApnDeliveryChannel({ apnService: service });
|
||||
expect(channel.canSend(baseNotification({}))).toBe(true);
|
||||
expect(channel.canSend(baseNotification({ channel: "email" }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("honours metadata topic / badge / sound / category overrides", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
await service.initialize();
|
||||
const channel = new ApnDeliveryChannel({
|
||||
apnService: service,
|
||||
defaultTopic: "fallback",
|
||||
});
|
||||
const result = await channel.send(
|
||||
baseNotification({
|
||||
metadata: {
|
||||
deviceToken: "tok1",
|
||||
topic: "override.topic",
|
||||
badge: 5,
|
||||
sound: "ping.caf",
|
||||
category: "MESSAGE",
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to defaultTopic when metadata.topic is absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
await service.initialize();
|
||||
const channel = new ApnDeliveryChannel({
|
||||
apnService: service,
|
||||
defaultTopic: "fallback.topic",
|
||||
});
|
||||
const result = await channel.send(
|
||||
baseNotification({ metadata: { deviceToken: "tok1" } })
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores deviceTokens when entries are not strings", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
await service.initialize();
|
||||
const channel = new ApnDeliveryChannel({ apnService: service });
|
||||
const result = await channel.send(
|
||||
baseNotification({
|
||||
metadata: { deviceTokens: [1, 2, 3] as unknown as string[] },
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("deviceToken metadata required");
|
||||
});
|
||||
|
||||
it("surfaces a generic error when APN returns failed with no errors[]", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const service = makeService();
|
||||
await service.initialize();
|
||||
// Monkey-patch sendNotification to simulate a transport that
|
||||
// reports zero sent without populating errors[].
|
||||
const original = service.sendNotification.bind(service);
|
||||
service.sendNotification = async () => ({ sent: 0, failed: 1 });
|
||||
const channel = new ApnDeliveryChannel({ apnService: service });
|
||||
const result = await channel.send(
|
||||
baseNotification({ metadata: { deviceToken: "tok1" } })
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("APN delivery failed");
|
||||
service.sendNotification = original;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import type {
|
||||
DeliveryChannel,
|
||||
DeliveryResult,
|
||||
Notification,
|
||||
} from "@suaveplan/notification-channel";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import type { ApnService } from "../../services/apn-service/apn-service.js";
|
||||
import type {
|
||||
PushNotificationPayload,
|
||||
PushNotificationResult,
|
||||
} from "../../services/apn-service/apn-service.types.js";
|
||||
|
||||
/** Construction options for {@link ApnDeliveryChannel}. */
|
||||
export interface ApnDeliveryChannelOptions {
|
||||
/** APN service used to actually deliver the push. */
|
||||
readonly apnService: ApnService;
|
||||
/** Default APN topic when the notification metadata omits one. */
|
||||
readonly defaultTopic?: string;
|
||||
/** Injectable clock for the `timestamp` field on the result. */
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
interface ApnMetadata {
|
||||
deviceToken?: unknown;
|
||||
deviceTokens?: unknown;
|
||||
topic?: unknown;
|
||||
badge?: unknown;
|
||||
sound?: unknown;
|
||||
category?: unknown;
|
||||
}
|
||||
|
||||
function defaultClock(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function nowIso(clock: Clock): string {
|
||||
return new Date(clock()).toISOString();
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
if (value.some((v) => typeof v !== "string" || v.length === 0)) {
|
||||
return undefined;
|
||||
}
|
||||
return value as string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter that exposes the in-package `ApnService` as a
|
||||
* `notification-channel` `DeliveryChannel`.
|
||||
*
|
||||
* Channel name: `apn`. Reads the device token (or token bag) from
|
||||
* `Notification.metadata.deviceToken` / `metadata.deviceTokens`. Maps
|
||||
* `Notification.body` → APN alert body and `Notification.subject` →
|
||||
* alert title.
|
||||
*/
|
||||
export class ApnDeliveryChannel implements DeliveryChannel {
|
||||
public readonly name = "apn";
|
||||
private readonly apn: ApnService;
|
||||
private readonly defaultTopic: string | undefined;
|
||||
private readonly clock: Clock;
|
||||
|
||||
public constructor(options: ApnDeliveryChannelOptions) {
|
||||
this.apn = options.apnService;
|
||||
this.defaultTopic = options.defaultTopic;
|
||||
this.clock = options.clock ?? defaultClock;
|
||||
}
|
||||
|
||||
public canSend(notification: Notification): boolean {
|
||||
return notification.channel === "push";
|
||||
}
|
||||
|
||||
public async send(notification: Notification): Promise<DeliveryResult> {
|
||||
const md = (notification.metadata ?? {}) as ApnMetadata;
|
||||
const tokens = asStringArray(md.deviceTokens);
|
||||
const single =
|
||||
typeof md.deviceToken === "string" && md.deviceToken.length > 0
|
||||
? md.deviceToken
|
||||
: undefined;
|
||||
if (tokens === undefined && single === undefined) {
|
||||
return {
|
||||
success: false,
|
||||
channel: this.name,
|
||||
error: "deviceToken metadata required",
|
||||
timestamp: nowIso(this.clock),
|
||||
};
|
||||
}
|
||||
|
||||
const payloadBase: Omit<PushNotificationPayload, "deviceToken"> = {
|
||||
alert: {
|
||||
...(notification.subject !== undefined
|
||||
? { title: notification.subject }
|
||||
: {}),
|
||||
body: notification.body,
|
||||
},
|
||||
...(typeof md.topic === "string" && md.topic.length > 0
|
||||
? { topic: md.topic }
|
||||
: this.defaultTopic !== undefined
|
||||
? { topic: this.defaultTopic }
|
||||
: {}),
|
||||
...(typeof md.badge === "number" ? { badge: md.badge } : {}),
|
||||
...(typeof md.sound === "string" && md.sound.length > 0
|
||||
? { sound: md.sound }
|
||||
: {}),
|
||||
...(typeof md.category === "string" && md.category.length > 0
|
||||
? { category: md.category }
|
||||
: {}),
|
||||
};
|
||||
|
||||
let result: PushNotificationResult;
|
||||
if (tokens !== undefined) {
|
||||
result = await this.apn.sendBulkNotifications(tokens, payloadBase);
|
||||
} else {
|
||||
// `single` proven non-undefined above; cast keeps the
|
||||
// exhaustiveness check tight without an unreachable else.
|
||||
result = await this.apn.sendNotification({
|
||||
...payloadBase,
|
||||
deviceToken: single as string,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.sent === 0) {
|
||||
const first = result.errors?.[0];
|
||||
const firstError =
|
||||
first !== undefined ? first.error : "APN delivery failed";
|
||||
return {
|
||||
success: false,
|
||||
channel: this.name,
|
||||
error: firstError,
|
||||
timestamp: nowIso(this.clock),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
channel: this.name,
|
||||
messageId: notification.id,
|
||||
timestamp: nowIso(this.clock),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
# email-channel
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Adapts `@suaveplan/emailer`'s `EmailService` to the
|
||||
`@suaveplan/notification-channel` `DeliveryChannel` interface so
|
||||
`NotificationDispatcher` can route email notifications through any of
|
||||
the emailer's three transports (Dummy / SMTP / Gmail REST). The
|
||||
channel is registered under the name `email` and canSend-gates on
|
||||
`notification.channel === "email"`. This module is the single place
|
||||
where the dispatcher's generic `Notification` shape is translated
|
||||
into an `EmailMessage` — keeping that translation out of the router
|
||||
and out of `EmailService`.
|
||||
|
||||
## Features
|
||||
|
||||
- Channel name `email`; canSend gates on `notification.channel ===
|
||||
"email"`.
|
||||
- Maps `Notification.recipient` → `EmailMessage.to` (the emailer's
|
||||
`AddressBag` accepts a single string), `Notification.subject` →
|
||||
`EmailMessage.subject` (with `defaultSubject` fallback when the
|
||||
notification carries no subject), `Notification.body` →
|
||||
`EmailMessage.text` (or `html` when `metadata.html === true`).
|
||||
- Folds `EmailService.send`'s `{ success: false, error }` outcomes
|
||||
into `DeliveryResult.error`. No throws for transport failures.
|
||||
- Refuses to construct with `emailService: null` — raises
|
||||
`NotificationsPluginError(EMAIL_SERVICE_UNAVAILABLE)` so the
|
||||
dispatcher fails fast at boot when an operator forgets to inject
|
||||
the service.
|
||||
- Surfaces the emailer's `messageId` when the transport returns one;
|
||||
falls back to the upstream notification id otherwise.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { EmailService, createDummyTransport } from "@suaveplan/emailer";
|
||||
import { EmailDeliveryChannel } from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
const email = new EmailService({
|
||||
transport: createDummyTransport({ logSends: true }),
|
||||
});
|
||||
await email.initialize();
|
||||
|
||||
const channel = new EmailDeliveryChannel({ emailService: email });
|
||||
const result = await channel.send({
|
||||
id: "notif_1",
|
||||
channel: "email",
|
||||
recipient: "user@example.com",
|
||||
subject: "Welcome",
|
||||
body: "Hello!",
|
||||
priority: "normal",
|
||||
status: "sent",
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Render an HTML body, configure a default subject, and wire into the
|
||||
router:
|
||||
|
||||
```ts
|
||||
import { NotificationRouter } from "@suaveplan/notification-channel";
|
||||
|
||||
const channel = new EmailDeliveryChannel({
|
||||
emailService: email,
|
||||
defaultSubject: "[YourApp] Notification",
|
||||
});
|
||||
|
||||
const router = new NotificationRouter({
|
||||
channels: [{ channel: "email", enabled: true }],
|
||||
defaultChannel: "email",
|
||||
deliveryChannels: [channel],
|
||||
});
|
||||
|
||||
await router.send({
|
||||
recipient: "user@example.com",
|
||||
channel: "email",
|
||||
body: "<h1>Welcome</h1><p>Glad you're here.</p>",
|
||||
metadata: { html: true },
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class EmailDeliveryChannel implements DeliveryChannel`
|
||||
- `name: "email"`
|
||||
- `canSend(n): boolean` — true iff `n.channel === "email"`
|
||||
- `send(n): Promise<DeliveryResult>`
|
||||
- `interface EmailDeliveryChannelOptions`
|
||||
- `emailService: EmailService | null` — null raises
|
||||
`EMAIL_SERVICE_UNAVAILABLE` at construction
|
||||
- `defaultSubject?: string` — default `"Notification"`
|
||||
- `clock?: Clock`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The emailer's `EmailMessage` requires `subject` to be a non-empty
|
||||
string; the channel always supplies one (via `defaultSubject` if
|
||||
the notification omits it).
|
||||
- `metadata.html === true` flips body delivery from `text` to `html`.
|
||||
Both formats are mutually exclusive — choose one per notification.
|
||||
- The channel exposes the emailer's transport-reported `messageId`
|
||||
on the `DeliveryResult` so callers can correlate delivery-tracking
|
||||
events with the underlying email message.
|
||||
- `EmailService.send` never throws (it folds errors into the result),
|
||||
so the channel doesn't need a try/catch around it.
|
||||
@@ -0,0 +1,150 @@
|
||||
import { createDummyTransport, EmailService } from "@suaveplan/emailer";
|
||||
import type { Notification } from "@suaveplan/notification-channel";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NOTIFICATIONS_PLUGIN_CODES } from "../../error-codes/error-codes.js";
|
||||
import { EmailDeliveryChannel } from "./email-channel.js";
|
||||
|
||||
function baseNotification(overrides: Partial<Notification>): Notification {
|
||||
return {
|
||||
id: "notif_1",
|
||||
channel: "email",
|
||||
recipient: "user@example.com",
|
||||
body: "Hello!",
|
||||
priority: "normal",
|
||||
status: "sent",
|
||||
createdAt: "2026-05-19T00:00:00.000Z",
|
||||
...overrides,
|
||||
} as Notification;
|
||||
}
|
||||
|
||||
async function makeEmailService(): Promise<EmailService> {
|
||||
const service = new EmailService({
|
||||
transport: createDummyTransport(),
|
||||
transportOptions: {},
|
||||
});
|
||||
await service.initialize();
|
||||
return service;
|
||||
}
|
||||
|
||||
describe("EmailDeliveryChannel", () => {
|
||||
it("sends through EmailService with subject + text", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const email = await makeEmailService();
|
||||
const channel = new EmailDeliveryChannel({ emailService: email });
|
||||
const result = await channel.send(baseNotification({ subject: "Hi" }));
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.channel).toBe("email");
|
||||
});
|
||||
|
||||
it("falls back to defaultSubject when notification has no subject", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const email = await makeEmailService();
|
||||
const channel = new EmailDeliveryChannel({
|
||||
emailService: email,
|
||||
defaultSubject: "Fallback Subject",
|
||||
});
|
||||
const result = await channel.send(baseNotification({}));
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("sends HTML body when metadata.html is true", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const email = await makeEmailService();
|
||||
const channel = new EmailDeliveryChannel({ emailService: email });
|
||||
const result = await channel.send(
|
||||
baseNotification({
|
||||
subject: "X",
|
||||
body: "<p>hi</p>",
|
||||
metadata: { html: true },
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("folds transport failure into DeliveryResult.error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const email = await makeEmailService();
|
||||
// Monkey-patch send to fail once.
|
||||
const original = email.send.bind(email);
|
||||
email.send = async () => ({
|
||||
success: false,
|
||||
error: new Error("smtp dead"),
|
||||
timestamp: new Date(),
|
||||
});
|
||||
const channel = new EmailDeliveryChannel({ emailService: email });
|
||||
const result = await channel.send(baseNotification({ subject: "x" }));
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("smtp dead");
|
||||
email.send = original;
|
||||
});
|
||||
|
||||
it("surfaces a fallback message when error is not an Error instance", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const email = await makeEmailService();
|
||||
const original = email.send.bind(email);
|
||||
email.send = async () => ({
|
||||
success: false,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
const channel = new EmailDeliveryChannel({ emailService: email });
|
||||
const result = await channel.send(baseNotification({ subject: "x" }));
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("email send failed");
|
||||
email.send = original;
|
||||
});
|
||||
|
||||
it("uses transport messageId when supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const email = await makeEmailService();
|
||||
const original = email.send.bind(email);
|
||||
email.send = async () => ({
|
||||
success: true,
|
||||
messageId: "msg-from-transport",
|
||||
timestamp: new Date(),
|
||||
});
|
||||
const channel = new EmailDeliveryChannel({ emailService: email });
|
||||
const result = await channel.send(baseNotification({ subject: "x" }));
|
||||
expect(result.messageId).toBe("msg-from-transport");
|
||||
email.send = original;
|
||||
});
|
||||
|
||||
it("falls back to notification id when transport omits messageId", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const email = await makeEmailService();
|
||||
const original = email.send.bind(email);
|
||||
email.send = async () => ({
|
||||
success: true,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
const channel = new EmailDeliveryChannel({ emailService: email });
|
||||
const result = await channel.send(baseNotification({ subject: "x" }));
|
||||
expect(result.messageId).toBe("notif_1");
|
||||
email.send = original;
|
||||
});
|
||||
|
||||
it("throws EMAIL_SERVICE_UNAVAILABLE when constructed with null service", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => new EmailDeliveryChannel({ emailService: null })).toThrow(
|
||||
expect.objectContaining({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.EMAIL_SERVICE_UNAVAILABLE,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("canSend gates on channel === email", { timeout: 5000 }, async () => {
|
||||
const email = await makeEmailService();
|
||||
const channel = new EmailDeliveryChannel({ emailService: email });
|
||||
expect(channel.canSend(baseNotification({}))).toBe(true);
|
||||
expect(channel.canSend(baseNotification({ channel: "push" }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { EmailService } from "@suaveplan/emailer";
|
||||
import type {
|
||||
DeliveryChannel,
|
||||
DeliveryResult,
|
||||
Notification,
|
||||
} from "@suaveplan/notification-channel";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import {
|
||||
NOTIFICATIONS_PLUGIN_CODES,
|
||||
NotificationsPluginError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
|
||||
/** Construction options for {@link EmailDeliveryChannel}. */
|
||||
export interface EmailDeliveryChannelOptions {
|
||||
/**
|
||||
* Email service used to deliver the message. MUST be non-null —
|
||||
* passing `null` raises `EMAIL_SERVICE_UNAVAILABLE` at
|
||||
* construction so the dispatcher fails fast at boot rather than
|
||||
* at first dispatch.
|
||||
*/
|
||||
readonly emailService: EmailService | null;
|
||||
/** Subject used when the notification carries no `subject`. */
|
||||
readonly defaultSubject?: string;
|
||||
/** Injectable clock for the `timestamp` field on the result. */
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
interface EmailMetadata {
|
||||
html?: unknown;
|
||||
}
|
||||
|
||||
function defaultClock(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function nowIso(clock: Clock): string {
|
||||
return new Date(clock()).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter that exposes `@suaveplan/emailer`'s `EmailService` as a
|
||||
* `notification-channel` `DeliveryChannel`.
|
||||
*
|
||||
* Channel name: `email`. Maps `Notification.recipient` →
|
||||
* `EmailMessage.to`, `Notification.subject` → `subject` (with
|
||||
* fallback to the configured `defaultSubject`), and
|
||||
* `Notification.body` → `text` (or `html` when
|
||||
* `metadata.html === true`).
|
||||
*/
|
||||
export class EmailDeliveryChannel implements DeliveryChannel {
|
||||
public readonly name = "email";
|
||||
private readonly email: EmailService;
|
||||
private readonly defaultSubject: string;
|
||||
private readonly clock: Clock;
|
||||
|
||||
public constructor(options: EmailDeliveryChannelOptions) {
|
||||
if (options.emailService === null) {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.EMAIL_SERVICE_UNAVAILABLE,
|
||||
"EmailDeliveryChannel requires a non-null EmailService"
|
||||
);
|
||||
}
|
||||
this.email = options.emailService;
|
||||
this.defaultSubject = options.defaultSubject ?? "Notification";
|
||||
this.clock = options.clock ?? defaultClock;
|
||||
}
|
||||
|
||||
public canSend(notification: Notification): boolean {
|
||||
return notification.channel === "email";
|
||||
}
|
||||
|
||||
public async send(notification: Notification): Promise<DeliveryResult> {
|
||||
const md = (notification.metadata ?? {}) as EmailMetadata;
|
||||
const isHtml = md.html === true;
|
||||
const subject = notification.subject ?? this.defaultSubject;
|
||||
const result = await this.email.send({
|
||||
to: notification.recipient,
|
||||
subject,
|
||||
...(isHtml
|
||||
? { html: notification.body }
|
||||
: { text: notification.body }),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
const errorMessage =
|
||||
result.error instanceof Error
|
||||
? result.error.message
|
||||
: "email send failed";
|
||||
return {
|
||||
success: false,
|
||||
channel: this.name,
|
||||
error: errorMessage,
|
||||
timestamp: nowIso(this.clock),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
channel: this.name,
|
||||
...(result.messageId !== undefined
|
||||
? { messageId: result.messageId }
|
||||
: { messageId: notification.id }),
|
||||
timestamp: nowIso(this.clock),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
# idempotency-adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the narrow contract that lets `NotificationDispatcher`
|
||||
delegate per-call deduplication to a caller-supplied implementation
|
||||
without `@suaveplan/api-plugin-notifications` taking a runtime
|
||||
dependency on `@suaveplan/idempotency` (or any other concrete guard).
|
||||
This keeps the dispatcher package light while still letting hosts plug
|
||||
a production-grade idempotency guard with cross-process locking when
|
||||
they need exactly-once semantics.
|
||||
|
||||
## Features
|
||||
|
||||
- `DispatcherIdempotencyAdapter` interface — a single method
|
||||
`execute<T>(key, fn): Promise<T>` that runs `fn` at most once per
|
||||
`key`.
|
||||
- `NoopIdempotencyAdapter` — the default adapter that just runs `fn()`
|
||||
every time. Used when the dispatcher is constructed without an
|
||||
explicit adapter, so the dispatch codepath is identical regardless
|
||||
of whether dedup is wired up.
|
||||
- Structural compatibility with
|
||||
`@suaveplan/idempotency`'s `IdempotencyGuard.execute<T>(key, fn, opts?)`
|
||||
— callers can hand the guard directly to the dispatcher
|
||||
(`adapter: guard`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { NoopIdempotencyAdapter } from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
const adapter = new NoopIdempotencyAdapter();
|
||||
const result = await adapter.execute("any-key", async () => 42);
|
||||
// result === 42, fn ran exactly once
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wire `@suaveplan/idempotency`'s `IdempotencyGuard` straight in — the
|
||||
signatures are structurally compatible:
|
||||
|
||||
```ts
|
||||
import { IdempotencyGuard, MemoryStore } from "@suaveplan/idempotency";
|
||||
import {
|
||||
createNotificationDispatcher,
|
||||
NoopIdempotencyAdapter,
|
||||
} from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
const guard = new IdempotencyGuard({ store: new MemoryStore() });
|
||||
|
||||
const dispatcher = createNotificationDispatcher({
|
||||
channels: [apnChannel, emailChannel],
|
||||
defaultChannel: "apn",
|
||||
// The guard satisfies `DispatcherIdempotencyAdapter` structurally.
|
||||
idempotency: { adapter: guard },
|
||||
});
|
||||
|
||||
// Same key twice → guard runs the closure once and returns the cached value.
|
||||
await dispatcher.dispatch({
|
||||
recipient: "u",
|
||||
body: "hi",
|
||||
idempotencyKey: "order-42",
|
||||
});
|
||||
await dispatcher.dispatch({
|
||||
recipient: "u",
|
||||
body: "hi",
|
||||
idempotencyKey: "order-42",
|
||||
});
|
||||
```
|
||||
|
||||
For tests where you want a tiny deterministic cache (no `idempotency`
|
||||
dep), write a 10-line custom adapter:
|
||||
|
||||
```ts
|
||||
class CachingAdapter implements DispatcherIdempotencyAdapter {
|
||||
private readonly cache = new Map<string, Promise<unknown>>();
|
||||
async execute<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||
let p = this.cache.get(key) as Promise<T> | undefined;
|
||||
if (p === undefined) {
|
||||
p = fn();
|
||||
this.cache.set(key, p as Promise<unknown>);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface DispatcherIdempotencyAdapter { execute<T>(key, fn): Promise<T> }`
|
||||
- `class NoopIdempotencyAdapter implements DispatcherIdempotencyAdapter`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The adapter is consulted only when `dispatch` receives a non-empty
|
||||
`idempotencyKey`. When the key is absent, the dispatcher bypasses
|
||||
the adapter entirely.
|
||||
- The contract is intentionally narrower than `IdempotencyGuard`'s
|
||||
`execute(key, fn, opts?)` — the `opts` parameter is optional in the
|
||||
guard, so omitting it from the adapter signature still leaves the
|
||||
guard usable as a drop-in.
|
||||
- `Noop` returns whatever the closure resolves to. It does NOT cache;
|
||||
duplicate dispatches with the same key trigger the underlying
|
||||
channel twice. That is the intended behaviour for the default — if
|
||||
you want dedup, plug a real guard.
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
type DispatcherIdempotencyAdapter,
|
||||
NoopIdempotencyAdapter,
|
||||
} from "./idempotency-adapter.js";
|
||||
|
||||
describe("NoopIdempotencyAdapter", () => {
|
||||
it("NoopIdempotencyAdapter calls fn exactly once", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new NoopIdempotencyAdapter();
|
||||
let calls = 0;
|
||||
const result = await adapter.execute("k", async () => {
|
||||
calls += 1;
|
||||
return 42;
|
||||
});
|
||||
expect(result).toBe(42);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("NoopIdempotencyAdapter does NOT dedupe repeated keys", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new NoopIdempotencyAdapter();
|
||||
let calls = 0;
|
||||
const fn = async () => {
|
||||
calls += 1;
|
||||
return calls;
|
||||
};
|
||||
const a = await adapter.execute("same-key", fn);
|
||||
const b = await adapter.execute("same-key", fn);
|
||||
expect(a).toBe(1);
|
||||
expect(b).toBe(2);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DispatcherIdempotencyAdapter — caching impl", () => {
|
||||
class CachingAdapter implements DispatcherIdempotencyAdapter {
|
||||
public readonly cache = new Map<string, Promise<unknown>>();
|
||||
public async execute<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||
let p = this.cache.get(key) as Promise<T> | undefined;
|
||||
if (p === undefined) {
|
||||
p = fn();
|
||||
this.cache.set(key, p as Promise<unknown>);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
it("a caching adapter dedupes repeated executions", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new CachingAdapter();
|
||||
let calls = 0;
|
||||
const fn = async () => {
|
||||
calls += 1;
|
||||
return calls;
|
||||
};
|
||||
const a = await adapter.execute("k", fn);
|
||||
const b = await adapter.execute("k", fn);
|
||||
expect(a).toBe(1);
|
||||
expect(b).toBe(1);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Narrow contract every dispatcher idempotency strategy must satisfy.
|
||||
*
|
||||
* The interface is intentionally minimal so callers can plug
|
||||
* `@suaveplan/idempotency`'s `IdempotencyGuard` (whose
|
||||
* `execute<T>(key, fn, opts?)` signature is structurally a superset)
|
||||
* without `@suaveplan/api-plugin-notifications` taking a runtime
|
||||
* dependency on that package.
|
||||
*/
|
||||
export interface DispatcherIdempotencyAdapter {
|
||||
/**
|
||||
* Execute `fn` at most once per `key`.
|
||||
*
|
||||
* Implementations MUST return the same value for repeated calls
|
||||
* with the same `key` (within whatever TTL the implementation
|
||||
* provides). A `NoopIdempotencyAdapter` simply calls `fn()` on
|
||||
* every invocation.
|
||||
*/
|
||||
execute<T>(key: string, fn: () => Promise<T>): Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default adapter — no idempotency, every call runs `fn()` fresh.
|
||||
*
|
||||
* This is the default when the dispatcher is constructed without an
|
||||
* explicit adapter, so the `NotificationDispatcher` codepath is the
|
||||
* same whether or not dedup is wired up.
|
||||
*/
|
||||
export class NoopIdempotencyAdapter implements DispatcherIdempotencyAdapter {
|
||||
public async execute<T>(_key: string, fn: () => Promise<T>): Promise<T> {
|
||||
return fn();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
# notification-dispatcher
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Top-level orchestrator that lets callers send one notification through
|
||||
whichever delivery channel they pick (APN, email, webhook, in-app)
|
||||
without knowing anything about the underlying transports. The
|
||||
dispatcher resolves a `@suaveplan/notification-channel`
|
||||
`DeliveryChannel` by the dispatcher-facing channel name (`apn`,
|
||||
`email`, …), validates every input through Zod, optionally threads
|
||||
the call through a `DispatcherIdempotencyAdapter` for exactly-once
|
||||
delivery, emits a `DeliveryEvent` on
|
||||
`@suaveplan/delivery-tracking`'s `DeliveryEventStore` for every
|
||||
dispatch (when one is configured), and increments two OTel counters
|
||||
on success / failure paths.
|
||||
|
||||
## Features
|
||||
|
||||
- `dispatch(input: DispatchInput): Promise<DispatchResult>` — single
|
||||
surface for multi-channel routing.
|
||||
- Zod boundary at the edge; invalid input folds into
|
||||
`NotificationsPluginError(DISPATCH_INPUT_INVALID)` with the
|
||||
`ZodError` as `cause`.
|
||||
- Channel selection: explicit `input.channel` wins; otherwise the
|
||||
configured `defaultChannel` is used. Unknown channels fail fast with
|
||||
`DISPATCH_CHANNEL_UNAVAILABLE`.
|
||||
- Channel-returned failures (`DeliveryResult.success === false`) fold
|
||||
into `DISPATCH_FAILED` carrying `context.channelError`.
|
||||
- Optional `DispatcherIdempotencyAdapter` — when an `idempotencyKey`
|
||||
is supplied, dispatches are routed through `adapter.execute(key,
|
||||
fn)` so duplicate calls return the cached result without hitting
|
||||
the underlying channel a second time.
|
||||
- Optional `DeliveryEventStore` — every dispatch emits a
|
||||
`DeliveryEvent` (`eventType: "sent"` on success, `"bounced"` on
|
||||
failure) so downstream analytics can query open / click / bounce
|
||||
rates per `messageId`.
|
||||
- OTel: `notificationsDispatchedTotal{channel}` on success,
|
||||
`notificationsDispatchFailedTotal{channel,reason}` on failure.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createNotificationDispatcher,
|
||||
ApnDeliveryChannel,
|
||||
ApnService,
|
||||
ApnConfigSchema,
|
||||
} from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
const apn = new ApnService({
|
||||
config: ApnConfigSchema.parse({
|
||||
token: { key: "k", keyId: "i", teamId: "t" },
|
||||
defaultTopic: "com.example.app",
|
||||
}),
|
||||
});
|
||||
await apn.initialize();
|
||||
|
||||
const dispatcher = createNotificationDispatcher({
|
||||
channels: [new ApnDeliveryChannel({ apnService: apn })],
|
||||
defaultChannel: "apn",
|
||||
});
|
||||
|
||||
const result = await dispatcher.dispatch({
|
||||
recipient: "user-1",
|
||||
body: "You have mail!",
|
||||
metadata: { deviceToken: "abc123" },
|
||||
});
|
||||
// result.status === "sent", result.channel === "apn"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wire APN + email channels, an idempotency guard, and delivery
|
||||
tracking together:
|
||||
|
||||
```ts
|
||||
import {
|
||||
EmailService,
|
||||
createDummyTransport,
|
||||
} from "@suaveplan/emailer";
|
||||
import {
|
||||
InMemoryDeliveryEventStore,
|
||||
} from "@suaveplan/delivery-tracking";
|
||||
import {
|
||||
createNotificationDispatcher,
|
||||
ApnDeliveryChannel,
|
||||
EmailDeliveryChannel,
|
||||
} from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
const email = new EmailService({ transport: createDummyTransport() });
|
||||
await email.initialize();
|
||||
|
||||
const store = new InMemoryDeliveryEventStore();
|
||||
|
||||
const dispatcher = createNotificationDispatcher({
|
||||
channels: [
|
||||
new ApnDeliveryChannel({ apnService: apn }),
|
||||
new EmailDeliveryChannel({ emailService: email }),
|
||||
],
|
||||
defaultChannel: "apn",
|
||||
deliveryStore: store,
|
||||
tenantId: "tenant-prod",
|
||||
});
|
||||
|
||||
// Send via email, with an idempotency key for safe retries.
|
||||
const r1 = await dispatcher.dispatch({
|
||||
recipient: "user@example.com",
|
||||
channel: "email",
|
||||
subject: "Order shipped",
|
||||
body: "Your order is on the way.",
|
||||
idempotencyKey: "order-42",
|
||||
});
|
||||
|
||||
// Inspect delivery-tracking outcomes for analytics.
|
||||
const events = await store.getByMessageId(r1.messageId);
|
||||
// events[0].eventType === "sent"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class NotificationDispatcher`
|
||||
- `constructor(options: NotificationDispatcherOptions)` — throws
|
||||
`DISPATCH_CHANNEL_UNAVAILABLE` when `channels.length === 0`.
|
||||
- `dispatch(input: DispatchInput): Promise<DispatchResult>` — main
|
||||
entry point.
|
||||
- `interface NotificationDispatcherOptions`
|
||||
- `channels: ReadonlyArray<DeliveryChannel>` (required)
|
||||
- `defaultChannel: DispatchChannel` (required)
|
||||
- `deliveryStore?: DeliveryEventStore`
|
||||
- `tenantId?: string` (default `"default"`)
|
||||
- `idempotency?: DispatcherIdempotencyAdapter` (default `NoopIdempotencyAdapter`)
|
||||
- `clock?: Clock`
|
||||
- `idGenerator?: IdGenerator`
|
||||
- `function createNotificationDispatcher(options): NotificationDispatcher`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The dispatcher wraps the upstream `NotificationRouter` rather than
|
||||
reimplementing routing — every retry, rate-limit, hook the router
|
||||
ships is available to dispatcher callers for free.
|
||||
- The router has no `apn` literal in its `NotificationChannel` union;
|
||||
the dispatcher maps our `apn` channel down to the upstream `push`
|
||||
literal so the router's bookkeeping works, while keeping the public
|
||||
`DispatchChannel` namespace specific.
|
||||
- `dispatcherChannelName` is stamped into `metadata` so the
|
||||
delivery-channel impl can sanity-check its own routing.
|
||||
- Delivery events use the dispatcher's `messageId` (which equals the
|
||||
router-issued `notif_<n>` id), so consumers querying
|
||||
`DeliveryEventStore.getByMessageId(dispatchResult.messageId)` will
|
||||
see exactly the events for that dispatch.
|
||||
- OTel labels are kept low-cardinality: `channel` is one of six
|
||||
literals; `reason` on the failure counter is either the router's
|
||||
reported error string or `"router_threw"` / `"router_failed"`.
|
||||
@@ -0,0 +1,419 @@
|
||||
import { InMemoryDeliveryEventStore } from "@suaveplan/delivery-tracking";
|
||||
import type {
|
||||
DeliveryChannel,
|
||||
DeliveryResult,
|
||||
Notification,
|
||||
} from "@suaveplan/notification-channel";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { flushTestMetrics } from "../../../tests/setup/polyfills.js";
|
||||
import { NOTIFICATIONS_PLUGIN_CODES } from "../../error-codes/error-codes.js";
|
||||
import { resetNotificationsInstruments } from "../../otel/instruments.js";
|
||||
import {
|
||||
type DispatcherIdempotencyAdapter,
|
||||
NoopIdempotencyAdapter,
|
||||
} from "../idempotency-adapter/idempotency-adapter.js";
|
||||
import {
|
||||
createNotificationDispatcher,
|
||||
NotificationDispatcher,
|
||||
} from "./notification-dispatcher.js";
|
||||
|
||||
class FakeChannel implements DeliveryChannel {
|
||||
public readonly name: string;
|
||||
public readonly calls: Notification[] = [];
|
||||
public readonly behaviour: "ok" | "failed" | "throws";
|
||||
|
||||
public constructor(
|
||||
name: string,
|
||||
behaviour: "ok" | "failed" | "throws" = "ok"
|
||||
) {
|
||||
this.name = name;
|
||||
this.behaviour = behaviour;
|
||||
}
|
||||
|
||||
public canSend(_: Notification): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
public async send(notification: Notification): Promise<DeliveryResult> {
|
||||
this.calls.push(notification);
|
||||
if (this.behaviour === "throws") {
|
||||
throw new Error("transport boom");
|
||||
}
|
||||
if (this.behaviour === "failed") {
|
||||
return {
|
||||
success: false,
|
||||
channel: this.name,
|
||||
error: "transport refused",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
channel: this.name,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class CachingAdapter implements DispatcherIdempotencyAdapter {
|
||||
public readonly cache = new Map<string, Promise<unknown>>();
|
||||
public async execute<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||
let p = this.cache.get(key) as Promise<T> | undefined;
|
||||
if (p === undefined) {
|
||||
p = fn();
|
||||
this.cache.set(key, p as Promise<unknown>);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
describe("NotificationDispatcher", () => {
|
||||
it("rejects construction with zero channels", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
() =>
|
||||
new NotificationDispatcher({
|
||||
channels: [],
|
||||
defaultChannel: "apn",
|
||||
})
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.DISPATCH_CHANNEL_UNAVAILABLE,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("dispatch rejects invalid input with DISPATCH_INPUT_INVALID", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const dispatcher = new NotificationDispatcher({
|
||||
channels: [new FakeChannel("apn")],
|
||||
defaultChannel: "apn",
|
||||
});
|
||||
await expect(
|
||||
dispatcher.dispatch({ recipient: "", body: "x" })
|
||||
).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.DISPATCH_INPUT_INVALID,
|
||||
});
|
||||
});
|
||||
|
||||
it("dispatch routes to the default channel", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
resetNotificationsInstruments();
|
||||
const apn = new FakeChannel("apn");
|
||||
const dispatcher = new NotificationDispatcher({
|
||||
channels: [apn],
|
||||
defaultChannel: "apn",
|
||||
});
|
||||
const r = await dispatcher.dispatch({
|
||||
recipient: "u",
|
||||
body: "hi",
|
||||
});
|
||||
expect(r.channel).toBe("apn");
|
||||
expect(r.status).toBe("sent");
|
||||
expect(apn.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("dispatch routes to an explicit channel", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const apn = new FakeChannel("apn");
|
||||
const email = new FakeChannel("email");
|
||||
const dispatcher = new NotificationDispatcher({
|
||||
channels: [apn, email],
|
||||
defaultChannel: "apn",
|
||||
});
|
||||
const r = await dispatcher.dispatch({
|
||||
recipient: "u@x",
|
||||
body: "hi",
|
||||
channel: "email",
|
||||
});
|
||||
expect(r.channel).toBe("email");
|
||||
expect(apn.calls).toHaveLength(0);
|
||||
expect(email.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("dispatch raises DISPATCH_CHANNEL_UNAVAILABLE for an unknown channel", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const dispatcher = new NotificationDispatcher({
|
||||
channels: [new FakeChannel("apn")],
|
||||
defaultChannel: "apn",
|
||||
});
|
||||
await expect(
|
||||
dispatcher.dispatch({
|
||||
recipient: "u",
|
||||
body: "hi",
|
||||
channel: "sms",
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.DISPATCH_CHANNEL_UNAVAILABLE,
|
||||
});
|
||||
});
|
||||
|
||||
it("dispatch raises DISPATCH_FAILED when the channel returns failed", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const dispatcher = new NotificationDispatcher({
|
||||
channels: [new FakeChannel("apn", "failed")],
|
||||
defaultChannel: "apn",
|
||||
});
|
||||
await expect(
|
||||
dispatcher.dispatch({ recipient: "u", body: "hi" })
|
||||
).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.DISPATCH_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("dispatch raises DISPATCH_FAILED when the channel throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const dispatcher = new NotificationDispatcher({
|
||||
channels: [new FakeChannel("apn", "throws")],
|
||||
defaultChannel: "apn",
|
||||
});
|
||||
await expect(
|
||||
dispatcher.dispatch({ recipient: "u", body: "hi" })
|
||||
).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.DISPATCH_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("dispatch records delivery:sent through the configured store", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = new InMemoryDeliveryEventStore();
|
||||
const dispatcher = new NotificationDispatcher({
|
||||
channels: [new FakeChannel("apn")],
|
||||
defaultChannel: "apn",
|
||||
deliveryStore: store,
|
||||
tenantId: "t-1",
|
||||
});
|
||||
const r = await dispatcher.dispatch({ recipient: "u", body: "hi" });
|
||||
const events = await store.getByMessageId(r.messageId);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.eventType).toBe("sent");
|
||||
expect(events[0]?.tenantId).toBe("t-1");
|
||||
});
|
||||
|
||||
it("dispatch records delivery:bounced when the channel fails", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = new InMemoryDeliveryEventStore();
|
||||
const dispatcher = new NotificationDispatcher({
|
||||
channels: [new FakeChannel("apn", "failed")],
|
||||
defaultChannel: "apn",
|
||||
deliveryStore: store,
|
||||
});
|
||||
await expect(
|
||||
dispatcher.dispatch({ recipient: "u", body: "hi" })
|
||||
).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.DISPATCH_FAILED,
|
||||
});
|
||||
const all = await store.getByContact("u");
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0]?.eventType).toBe("bounced");
|
||||
});
|
||||
|
||||
it("idempotency adapter dedupes repeated dispatches", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new CachingAdapter();
|
||||
const apn = new FakeChannel("apn");
|
||||
const dispatcher = new NotificationDispatcher({
|
||||
channels: [apn],
|
||||
defaultChannel: "apn",
|
||||
idempotency: adapter,
|
||||
});
|
||||
const r1 = await dispatcher.dispatch({
|
||||
recipient: "u",
|
||||
body: "hi",
|
||||
idempotencyKey: "key-1",
|
||||
});
|
||||
const r2 = await dispatcher.dispatch({
|
||||
recipient: "u",
|
||||
body: "hi",
|
||||
idempotencyKey: "key-1",
|
||||
});
|
||||
expect(r1.id).toBe(r2.id);
|
||||
expect(apn.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("noop adapter does NOT dedupe", { timeout: 5000 }, async () => {
|
||||
const apn = new FakeChannel("apn");
|
||||
const dispatcher = new NotificationDispatcher({
|
||||
channels: [apn],
|
||||
defaultChannel: "apn",
|
||||
idempotency: new NoopIdempotencyAdapter(),
|
||||
});
|
||||
await dispatcher.dispatch({
|
||||
recipient: "u",
|
||||
body: "hi",
|
||||
idempotencyKey: "key-1",
|
||||
});
|
||||
await dispatcher.dispatch({
|
||||
recipient: "u",
|
||||
body: "hi",
|
||||
idempotencyKey: "key-1",
|
||||
});
|
||||
expect(apn.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("dispatch emits notificationsDispatchedTotal on success", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
resetNotificationsInstruments();
|
||||
const dispatcher = new NotificationDispatcher({
|
||||
channels: [new FakeChannel("apn")],
|
||||
defaultChannel: "apn",
|
||||
});
|
||||
await dispatcher.dispatch({ recipient: "u", body: "hi" });
|
||||
const snapshots = await flushTestMetrics();
|
||||
const names = snapshots.flatMap((s) =>
|
||||
s.scopeMetrics.flatMap((sm) =>
|
||||
sm.metrics.map((m) => m.descriptor.name)
|
||||
)
|
||||
);
|
||||
expect(names).toContain("notifications.dispatched_total");
|
||||
});
|
||||
|
||||
it("dispatch emits notificationsDispatchFailedTotal on failure", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
resetNotificationsInstruments();
|
||||
const dispatcher = new NotificationDispatcher({
|
||||
channels: [new FakeChannel("apn", "failed")],
|
||||
defaultChannel: "apn",
|
||||
});
|
||||
await expect(
|
||||
dispatcher.dispatch({ recipient: "u", body: "hi" })
|
||||
).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.DISPATCH_FAILED,
|
||||
});
|
||||
const snapshots = await flushTestMetrics();
|
||||
const names = snapshots.flatMap((s) =>
|
||||
s.scopeMetrics.flatMap((sm) =>
|
||||
sm.metrics.map((m) => m.descriptor.name)
|
||||
)
|
||||
);
|
||||
expect(names).toContain("notifications.dispatch_failed_total");
|
||||
});
|
||||
|
||||
it("uses injected clock + idGenerator for delivery events", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = new InMemoryDeliveryEventStore();
|
||||
let n = 100;
|
||||
const dispatcher = new NotificationDispatcher({
|
||||
channels: [new FakeChannel("apn")],
|
||||
defaultChannel: "apn",
|
||||
deliveryStore: store,
|
||||
clock: () => 1700000000000,
|
||||
idGenerator: () => `evt-${++n}`,
|
||||
});
|
||||
const r = await dispatcher.dispatch({
|
||||
recipient: "u",
|
||||
body: "hi",
|
||||
});
|
||||
const events = await store.getByMessageId(r.messageId);
|
||||
expect(events[0]?.id).toBe("evt-101");
|
||||
expect(events[0]?.timestamp.getTime()).toBe(1700000000000);
|
||||
});
|
||||
|
||||
it("folds non-Error throws into DISPATCH_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
class StringThrower implements DeliveryChannel {
|
||||
public readonly name = "apn";
|
||||
public canSend(): boolean {
|
||||
return true;
|
||||
}
|
||||
public async send(): Promise<DeliveryResult> {
|
||||
throw "weird thrown string";
|
||||
}
|
||||
}
|
||||
const store = new InMemoryDeliveryEventStore();
|
||||
const dispatcher = new NotificationDispatcher({
|
||||
channels: [new StringThrower()],
|
||||
defaultChannel: "apn",
|
||||
deliveryStore: store,
|
||||
});
|
||||
await expect(
|
||||
dispatcher.dispatch({ recipient: "u", body: "hi" })
|
||||
).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.DISPATCH_FAILED,
|
||||
});
|
||||
const events = await store.getByContact("u");
|
||||
expect(events[0]?.metadata.error).toBe("weird thrown string");
|
||||
});
|
||||
|
||||
it("falls back to default reason when channel returns failed without error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
class FailWithoutMessage implements DeliveryChannel {
|
||||
public readonly name = "apn";
|
||||
public canSend(): boolean {
|
||||
return true;
|
||||
}
|
||||
public async send(): Promise<DeliveryResult> {
|
||||
return {
|
||||
success: false,
|
||||
channel: "apn",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
const store = new InMemoryDeliveryEventStore();
|
||||
const dispatcher = new NotificationDispatcher({
|
||||
channels: [new FailWithoutMessage()],
|
||||
defaultChannel: "apn",
|
||||
deliveryStore: store,
|
||||
});
|
||||
await expect(
|
||||
dispatcher.dispatch({ recipient: "u", body: "hi" })
|
||||
).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.DISPATCH_FAILED,
|
||||
});
|
||||
const events = await store.getByContact("u");
|
||||
expect(events[0]?.metadata.error).toBe("channel reported failed");
|
||||
});
|
||||
|
||||
it("supports input without metadata", { timeout: 5000 }, async () => {
|
||||
const apn = new FakeChannel("apn");
|
||||
const dispatcher = new NotificationDispatcher({
|
||||
channels: [apn],
|
||||
defaultChannel: "apn",
|
||||
});
|
||||
await dispatcher.dispatch({ recipient: "u", body: "hi" });
|
||||
// metadata fold-in path with `safe.metadata ?? {}` exercised
|
||||
expect(apn.calls[0]?.metadata?.dispatcherChannelName).toBe("apn");
|
||||
});
|
||||
|
||||
it("createNotificationDispatcher factory builds an instance", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const d = createNotificationDispatcher({
|
||||
channels: [new FakeChannel("apn")],
|
||||
defaultChannel: "apn",
|
||||
});
|
||||
expect(d).toBeInstanceOf(NotificationDispatcher);
|
||||
});
|
||||
|
||||
it("dispatch threads subject + priority into the router", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const apn = new FakeChannel("apn");
|
||||
const dispatcher = new NotificationDispatcher({
|
||||
channels: [apn],
|
||||
defaultChannel: "apn",
|
||||
});
|
||||
await dispatcher.dispatch({
|
||||
recipient: "u",
|
||||
body: "hi",
|
||||
subject: "Subj",
|
||||
priority: "high",
|
||||
});
|
||||
expect(apn.calls[0]?.subject).toBe("Subj");
|
||||
expect(apn.calls[0]?.priority).toBe("high");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import type { DeliveryEventStore } from "@suaveplan/delivery-tracking";
|
||||
import type {
|
||||
DeliveryChannel,
|
||||
Notification,
|
||||
NotificationChannel,
|
||||
} from "@suaveplan/notification-channel";
|
||||
import type { Clock, IdGenerator } from "@suaveplan/types";
|
||||
import {
|
||||
NOTIFICATIONS_PLUGIN_CODES,
|
||||
NotificationsPluginError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { getNotificationsInstruments } from "../../otel/instruments.js";
|
||||
import {
|
||||
type DispatchChannel,
|
||||
type DispatchInput,
|
||||
DispatchInputSchema,
|
||||
type DispatchResult,
|
||||
} from "../../schemas/dispatch-schemas.js";
|
||||
import {
|
||||
type DispatcherIdempotencyAdapter,
|
||||
NoopIdempotencyAdapter,
|
||||
} from "../idempotency-adapter/idempotency-adapter.js";
|
||||
|
||||
/** Construction options for the dispatcher. */
|
||||
export interface NotificationDispatcherOptions {
|
||||
readonly channels: ReadonlyArray<DeliveryChannel>;
|
||||
readonly defaultChannel: DispatchChannel;
|
||||
readonly deliveryStore?: DeliveryEventStore;
|
||||
readonly tenantId?: string;
|
||||
readonly idempotency?: DispatcherIdempotencyAdapter;
|
||||
readonly clock?: Clock;
|
||||
readonly idGenerator?: IdGenerator;
|
||||
}
|
||||
|
||||
function defaultClock(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function makeDefaultIdGenerator(clock: Clock): IdGenerator {
|
||||
let counter = 0;
|
||||
return () => {
|
||||
counter += 1;
|
||||
return `dispatch_${clock()}_${counter}`;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map our `DispatchChannel` (which adds `apn` to the upstream union)
|
||||
* down to a `NotificationChannel` literal accepted by the upstream
|
||||
* `Notification` shape. The router has no `apn` literal — APN
|
||||
* notifications carry `channel: "push"` on the wire.
|
||||
*/
|
||||
function toUpstreamChannel(channel: DispatchChannel): NotificationChannel {
|
||||
return channel === "apn" ? "push" : channel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrator built on top of `@suaveplan/notification-channel`'s
|
||||
* `DeliveryChannel` contract. Adds:
|
||||
*
|
||||
* - Zod-validated public boundary.
|
||||
* - Optional per-call idempotency through a pluggable adapter.
|
||||
* - Delivery-tracking emission through
|
||||
* `@suaveplan/delivery-tracking`'s `DeliveryEventStore`.
|
||||
* - OTel counters on success and failure paths.
|
||||
*
|
||||
* The dispatcher resolves the target `DeliveryChannel` by the
|
||||
* dispatcher-facing channel name (`apn`, `email`, …) and invokes the
|
||||
* channel's `send` directly. This keeps the dispatcher thin and
|
||||
* keeps every channel's behaviour testable in isolation.
|
||||
*/
|
||||
export class NotificationDispatcher {
|
||||
private readonly defaultChannel: DispatchChannel;
|
||||
private readonly store: DeliveryEventStore | undefined;
|
||||
private readonly tenantId: string;
|
||||
private readonly idempotency: DispatcherIdempotencyAdapter;
|
||||
private readonly clock: Clock;
|
||||
private readonly idGenerator: IdGenerator;
|
||||
private readonly channelsByName: Map<string, DeliveryChannel>;
|
||||
private nextDispatchId = 1;
|
||||
|
||||
public constructor(options: NotificationDispatcherOptions) {
|
||||
if (options.channels.length === 0) {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.DISPATCH_CHANNEL_UNAVAILABLE,
|
||||
"NotificationDispatcher requires at least one DeliveryChannel"
|
||||
);
|
||||
}
|
||||
this.defaultChannel = options.defaultChannel;
|
||||
this.store = options.deliveryStore;
|
||||
this.tenantId = options.tenantId ?? "default";
|
||||
this.idempotency = options.idempotency ?? new NoopIdempotencyAdapter();
|
||||
this.clock = options.clock ?? defaultClock;
|
||||
this.idGenerator =
|
||||
options.idGenerator ?? makeDefaultIdGenerator(this.clock);
|
||||
this.channelsByName = new Map(
|
||||
options.channels.map((c) => [c.name, c] as const)
|
||||
);
|
||||
}
|
||||
|
||||
public async dispatch(input: DispatchInput): Promise<DispatchResult> {
|
||||
const parsed = DispatchInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.DISPATCH_INPUT_INVALID,
|
||||
"NotificationDispatcher.dispatch received invalid input",
|
||||
{
|
||||
cause: parsed.error,
|
||||
context: { issues: parsed.error.issues },
|
||||
}
|
||||
);
|
||||
}
|
||||
const safe = parsed.data;
|
||||
const channel = safe.channel ?? this.defaultChannel;
|
||||
const deliveryChannel = this.channelsByName.get(channel);
|
||||
if (deliveryChannel === undefined) {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.DISPATCH_CHANNEL_UNAVAILABLE,
|
||||
`No DeliveryChannel registered for '${channel}'`,
|
||||
{ context: { channel } }
|
||||
);
|
||||
}
|
||||
|
||||
const exec = async (): Promise<DispatchResult> =>
|
||||
this.executeOnce(safe, channel, deliveryChannel);
|
||||
|
||||
if (safe.idempotencyKey !== undefined) {
|
||||
return this.idempotency.execute(safe.idempotencyKey, exec);
|
||||
}
|
||||
return exec();
|
||||
}
|
||||
|
||||
private async executeOnce(
|
||||
safe: DispatchInput,
|
||||
channel: DispatchChannel,
|
||||
deliveryChannel: DeliveryChannel
|
||||
): Promise<DispatchResult> {
|
||||
const inst = getNotificationsInstruments();
|
||||
const id = `notif_${this.nextDispatchId++}`;
|
||||
const now = new Date(this.clock()).toISOString();
|
||||
const notification: Notification = {
|
||||
id,
|
||||
channel: toUpstreamChannel(channel),
|
||||
recipient: safe.recipient,
|
||||
...(safe.subject !== undefined ? { subject: safe.subject } : {}),
|
||||
body: safe.body,
|
||||
priority: safe.priority ?? "normal",
|
||||
status: "sent",
|
||||
metadata: {
|
||||
...(safe.metadata ?? {}),
|
||||
dispatcherChannelName: deliveryChannel.name,
|
||||
},
|
||||
createdAt: now,
|
||||
sentAt: now,
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await deliveryChannel.send(notification);
|
||||
|
||||
if (!result.success) {
|
||||
await this.emitDeliveryEvent(
|
||||
"bounced",
|
||||
id,
|
||||
channel,
|
||||
safe.recipient,
|
||||
{ error: result.error ?? "channel reported failed" }
|
||||
);
|
||||
inst.notificationsDispatchFailedTotal.add(1, {
|
||||
channel,
|
||||
reason: result.error ?? "channel_failed",
|
||||
});
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.DISPATCH_FAILED,
|
||||
`Dispatch through channel '${channel}' failed`,
|
||||
{
|
||||
context: {
|
||||
channel,
|
||||
channelError: result.error,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
await this.emitDeliveryEvent(
|
||||
"sent",
|
||||
id,
|
||||
channel,
|
||||
safe.recipient,
|
||||
{}
|
||||
);
|
||||
inst.notificationsDispatchedTotal.add(1, { channel });
|
||||
|
||||
return {
|
||||
id,
|
||||
messageId: id,
|
||||
channel,
|
||||
status: "sent",
|
||||
sentAt: now,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof NotificationsPluginError) throw err;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await this.emitDeliveryEvent(
|
||||
"bounced",
|
||||
id,
|
||||
channel,
|
||||
safe.recipient,
|
||||
{ error: message }
|
||||
);
|
||||
inst.notificationsDispatchFailedTotal.add(1, {
|
||||
channel,
|
||||
reason: "channel_threw",
|
||||
});
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.DISPATCH_FAILED,
|
||||
`Dispatch through channel '${channel}' threw: ${message}`,
|
||||
{
|
||||
cause: err instanceof Error ? err : undefined,
|
||||
context: { channel },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async emitDeliveryEvent(
|
||||
eventType: "sent" | "bounced",
|
||||
messageId: string,
|
||||
channel: DispatchChannel,
|
||||
recipient: string,
|
||||
metadata: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
if (this.store === undefined) return;
|
||||
await this.store.record({
|
||||
id: this.idGenerator(),
|
||||
tenantId: this.tenantId,
|
||||
contactId: recipient,
|
||||
channel,
|
||||
eventType,
|
||||
messageId,
|
||||
metadata,
|
||||
timestamp: new Date(this.clock()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Factory matching the package's `create*` naming convention. */
|
||||
export function createNotificationDispatcher(
|
||||
options: NotificationDispatcherOptions
|
||||
): NotificationDispatcher {
|
||||
return new NotificationDispatcher(options);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Stable error-code registry for `@suaveplan/api-plugin-notifications`. The
|
||||
module exports a frozen `NOTIFICATIONS_PLUGIN_CODES` map produced by the
|
||||
`defineCodes("NOTIFICATIONS_PLUGIN", [...])` helper from `@suaveplan/error`,
|
||||
plus a concrete `NotificationsPluginError` class that extends
|
||||
`SuaveplanError<NotificationsPluginErrorCode>`. The namespace literal
|
||||
deliberately differs from `EVENTS` (owned by `@suaveplan/events`) and
|
||||
`EVENTS_PLUGIN` (owned by `@suaveplan/api-plugin-events`) so the global
|
||||
registry never silently shadows codes registered by sibling packages.
|
||||
|
||||
## Features
|
||||
|
||||
- Eighteen stable codes spanning configuration, lifecycle, repository,
|
||||
device-token, route, and APN-transport failure modes.
|
||||
- Type-level narrowing via `NotificationsPluginErrorCode` so any
|
||||
`instanceof NotificationsPluginError` check pins `error.code` to one
|
||||
of the declared literals.
|
||||
- Structured `context` and `cause` passthrough — the constructor
|
||||
forwards every `SuaveplanErrorOptions` field to the base class so
|
||||
Zod `ZodError`s, raw `Error`s, and arbitrary telemetry context all
|
||||
travel together.
|
||||
- A spec-conformance test reflects the registry at load time and
|
||||
greps the rest of `src/` to assert every code is referenced from a
|
||||
real `throw new NotificationsPluginError(...)` site.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
NOTIFICATIONS_PLUGIN_CODES,
|
||||
NotificationsPluginError,
|
||||
} from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.NOTIFICATION_NOT_FOUND,
|
||||
"Notification not found",
|
||||
{ context: { id: "notification:abc" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Catch handlers narrow `error.code` to the registered literals so
|
||||
branch-by-code routing stays type-safe without string literals leaking:
|
||||
|
||||
```ts
|
||||
try {
|
||||
await createNotification(...);
|
||||
} catch (err) {
|
||||
if (err instanceof NotificationsPluginError) {
|
||||
switch (err.code) {
|
||||
case NOTIFICATIONS_PLUGIN_CODES.APN_SEND_FAILED:
|
||||
case NOTIFICATIONS_PLUGIN_CODES.APN_SERVICE_UNAVAILABLE:
|
||||
return scheduleRetry(err);
|
||||
case NOTIFICATIONS_PLUGIN_CODES.UNAUTHENTICATED:
|
||||
case NOTIFICATIONS_PLUGIN_CODES.FORBIDDEN:
|
||||
return reject403(err);
|
||||
default:
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NOTIFICATIONS_PLUGIN_CODES` — frozen record of literal codes
|
||||
(`{ INVALID_CONFIG: "NOTIFICATIONS_PLUGIN.INVALID_CONFIG", ... }`).
|
||||
- `NotificationsPluginErrorCode` — type alias of the value union.
|
||||
- `NotificationsPluginError(code, message, options?)` — concrete error
|
||||
class. Stores `code` on both the base `code` field and a redundant
|
||||
`notificationsPluginCode` field to keep type narrowing crisp when the
|
||||
base class is generic over the union.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` accepts a namespace literal and a `readonly` tuple of
|
||||
suffix literals; the helper returns a frozen record keyed by the suffix
|
||||
with values `"<namespace>.<suffix>"`. Mutating the result is rejected
|
||||
at runtime by the `Object.freeze` step inside `@suaveplan/error`. The
|
||||
spec-conformance test depends on the constant identifier
|
||||
(`NOTIFICATIONS_PLUGIN_CODES.SOMETHING`) appearing literally in source
|
||||
files, so codes MUST be referenced by their constant name — not the
|
||||
string literal — at every throw site.
|
||||
@@ -0,0 +1,79 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
NOTIFICATIONS_PLUGIN_CODES,
|
||||
NotificationsPluginError,
|
||||
} from "./error-codes.js";
|
||||
|
||||
const SRC_ROOT = join(__dirname, "..");
|
||||
|
||||
async function* walk(dir: string): AsyncGenerator<string> {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) yield* walk(path);
|
||||
else if (
|
||||
entry.isFile() &&
|
||||
path.endsWith(".ts") &&
|
||||
!path.endsWith(".test.ts")
|
||||
) {
|
||||
yield path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readAllSources(): Promise<string> {
|
||||
let combined = "";
|
||||
for await (const path of walk(SRC_ROOT)) {
|
||||
combined += await readFile(path, "utf8");
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
|
||||
describe("NOTIFICATIONS_PLUGIN_CODES", () => {
|
||||
it("namespace literal is NOTIFICATIONS_PLUGIN", { timeout: 5000 }, () => {
|
||||
for (const value of Object.values(NOTIFICATIONS_PLUGIN_CODES)) {
|
||||
expect(value.startsWith("NOTIFICATIONS_PLUGIN.")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("constructs NotificationsPluginError with the declared code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.INVALID_CONFIG,
|
||||
"boom"
|
||||
);
|
||||
expect(err.code).toBe(NOTIFICATIONS_PLUGIN_CODES.INVALID_CONFIG);
|
||||
expect(err.notificationsPluginCode).toBe(
|
||||
NOTIFICATIONS_PLUGIN_CODES.INVALID_CONFIG
|
||||
);
|
||||
expect(err.message).toBe("boom");
|
||||
});
|
||||
|
||||
it("every NOTIFICATIONS_PLUGIN_CODES value is referenced by src/", {
|
||||
timeout: 15000,
|
||||
}, async () => {
|
||||
const combined = await readAllSources();
|
||||
for (const [key, value] of Object.entries(NOTIFICATIONS_PLUGIN_CODES)) {
|
||||
const probe = `NOTIFICATIONS_PLUGIN_CODES.${key}`;
|
||||
if (!combined.includes(probe)) {
|
||||
throw new Error(
|
||||
`Code ${value} (${probe}) is never referenced by a production source file`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves cause and context options", { timeout: 5000 }, () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.SERVICE_NOT_AVAILABLE,
|
||||
"outer",
|
||||
{ cause, context: { foo: "bar" } }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({ foo: "bar" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-plugin-notifications`.
|
||||
*
|
||||
* The namespace literal is `"NOTIFICATIONS_PLUGIN"` so the registry
|
||||
* never collides with the typed-event-bus library `@suaveplan/events`
|
||||
* or with the `EVENTS_PLUGIN` namespace owned by
|
||||
* `@suaveplan/api-plugin-events`.
|
||||
*
|
||||
* Every code is thrown from at least one production path under `src/`.
|
||||
* The spec-conformance test
|
||||
* (`src/error-codes/error-codes.test.ts`) reflects over the registry
|
||||
* and asserts every code is reachable.
|
||||
*/
|
||||
export const NOTIFICATIONS_PLUGIN_CODES = defineCodes("NOTIFICATIONS_PLUGIN", [
|
||||
"INVALID_CONFIG",
|
||||
"SERVICE_NOT_AVAILABLE",
|
||||
"ROUTE_PREFIX_COLLISION",
|
||||
"NOTIFICATION_CREATE_FAILED",
|
||||
"NOTIFICATION_UPDATE_FAILED",
|
||||
"NOTIFICATION_NOT_FOUND",
|
||||
"DEVICE_TOKEN_CREATE_FAILED",
|
||||
"DEVICE_TOKEN_NOT_FOUND",
|
||||
"UNAUTHENTICATED",
|
||||
"FORBIDDEN",
|
||||
"APN_CONFIG_MISSING",
|
||||
"APN_CONFIG_INVALID",
|
||||
"APN_NOT_INITIALIZED",
|
||||
"APN_TRANSPORT_INIT_FAILED",
|
||||
"APN_TRANSPORT_SHUTDOWN_FAILED",
|
||||
"APN_SERVICE_UNAVAILABLE",
|
||||
"DEVICE_TOKEN_DUPLICATE",
|
||||
"DISPATCH_FAILED",
|
||||
"DISPATCH_CHANNEL_UNAVAILABLE",
|
||||
"DISPATCH_INPUT_INVALID",
|
||||
"EMAIL_SERVICE_UNAVAILABLE",
|
||||
] as const);
|
||||
|
||||
export type NotificationsPluginErrorCode =
|
||||
(typeof NOTIFICATIONS_PLUGIN_CODES)[keyof typeof NOTIFICATIONS_PLUGIN_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class. Extends {@link SuaveplanError} so
|
||||
* `instanceof NotificationsPluginError` narrows `error.code` to
|
||||
* {@link NotificationsPluginErrorCode}.
|
||||
*/
|
||||
export class NotificationsPluginError extends SuaveplanError<NotificationsPluginErrorCode> {
|
||||
public readonly notificationsPluginCode: NotificationsPluginErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: NotificationsPluginErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.notificationsPluginCode = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { NotificationsPluginErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
NOTIFICATIONS_PLUGIN_CODES,
|
||||
NotificationsPluginError,
|
||||
} from "./error-codes.js";
|
||||
169
packages/api-stack/api/api-plugin-notifications/src/index.ts
Normal file
169
packages/api-stack/api/api-plugin-notifications/src/index.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
// Side-effect imports — surface the error-codes registry and the
|
||||
// service-registry augmentation to every consumer importing the
|
||||
// package barrel.
|
||||
import "./error-codes/error-codes.js";
|
||||
import "./services/registry-augmentation.js";
|
||||
|
||||
// Channels
|
||||
export {
|
||||
ApnDeliveryChannel,
|
||||
type ApnDeliveryChannelOptions,
|
||||
} from "./channels/apn-channel/apn-channel.js";
|
||||
export {
|
||||
EmailDeliveryChannel,
|
||||
type EmailDeliveryChannelOptions,
|
||||
} from "./channels/email-channel/email-channel.js";
|
||||
// Dispatcher
|
||||
export {
|
||||
type DispatcherIdempotencyAdapter,
|
||||
NoopIdempotencyAdapter,
|
||||
} from "./dispatcher/idempotency-adapter/idempotency-adapter.js";
|
||||
export {
|
||||
createNotificationDispatcher,
|
||||
NotificationDispatcher,
|
||||
type NotificationDispatcherOptions,
|
||||
} from "./dispatcher/notification-dispatcher/notification-dispatcher.js";
|
||||
// Error codes
|
||||
export {
|
||||
NOTIFICATIONS_PLUGIN_CODES,
|
||||
NotificationsPluginError,
|
||||
type NotificationsPluginErrorCode,
|
||||
} from "./error-codes/error-codes.js";
|
||||
// OTel
|
||||
export {
|
||||
getNotificationsInstruments,
|
||||
type NotificationsInstruments,
|
||||
resetNotificationsInstruments,
|
||||
} from "./otel/instruments.js";
|
||||
// Plugin
|
||||
export {
|
||||
createNotificationsPlugin,
|
||||
type NotificationsPluginServices,
|
||||
} from "./plugin/plugin.js";
|
||||
export { DeviceTokenRepository } from "./repositories/device-token-repository/device-token-repository.js";
|
||||
export type {
|
||||
DeviceTokenRepositoryConfig,
|
||||
IDeviceTokenRepository,
|
||||
} from "./repositories/device-token-repository/device-token-repository.types.js";
|
||||
// Repositories
|
||||
export { NotificationRepository } from "./repositories/notification-repository/notification-repository.js";
|
||||
export type {
|
||||
BulkOperationResult,
|
||||
FindNotificationsOptions,
|
||||
GroupedNotifications,
|
||||
INotificationRepository,
|
||||
NotificationRepositoryConfig,
|
||||
} from "./repositories/notification-repository/notification-repository.types.js";
|
||||
|
||||
// Routes
|
||||
export {
|
||||
createNotificationsRoutes,
|
||||
type NotificationsRoutesVariables,
|
||||
} from "./routes/notifications-routes/notifications-routes.js";
|
||||
export {
|
||||
createPushRoutes,
|
||||
type PushRoutesVariables,
|
||||
} from "./routes/push-routes/push-routes.js";
|
||||
// Schemas — dispatch surface
|
||||
export {
|
||||
type DispatchChannel,
|
||||
DispatchChannelSchema,
|
||||
type DispatchInput,
|
||||
DispatchInputSchema,
|
||||
type DispatchPriority,
|
||||
DispatchPrioritySchema,
|
||||
type DispatchResult,
|
||||
DispatchResultSchema,
|
||||
} from "./schemas/dispatch-schemas.js";
|
||||
// Schemas — notification entity
|
||||
export {
|
||||
type BulkOperationResponse,
|
||||
BulkOperationResponseSchema,
|
||||
type CreateNotification,
|
||||
CreateNotificationSchema,
|
||||
type GroupedNotifications as GroupedNotificationsType,
|
||||
GroupedNotificationsSchema,
|
||||
type MarkManyAsRead,
|
||||
MarkManyAsReadSchema,
|
||||
type Notification,
|
||||
type NotificationIdParam,
|
||||
NotificationIdParamSchema,
|
||||
type NotificationListResponse,
|
||||
NotificationListResponseSchema,
|
||||
type NotificationMetadata,
|
||||
NotificationMetadataSchema,
|
||||
type NotificationQuery,
|
||||
NotificationQuerySchema,
|
||||
NotificationSchema,
|
||||
type NotificationType,
|
||||
NotificationTypeSchema,
|
||||
type TargetType,
|
||||
TargetTypeSchema,
|
||||
type UnreadCountResponse,
|
||||
UnreadCountResponseSchema,
|
||||
type UpdateNotification,
|
||||
UpdateNotificationSchema,
|
||||
} from "./schemas/notification-schemas.js";
|
||||
// Schemas — options
|
||||
export {
|
||||
type ApnConfig,
|
||||
ApnConfigSchema,
|
||||
type NotificationsPluginConfig,
|
||||
type NotificationsPluginConfigInput,
|
||||
NotificationsPluginOptionsSchema,
|
||||
} from "./schemas/options.js";
|
||||
// Schemas — push wire
|
||||
export {
|
||||
type DeviceEnvironment,
|
||||
DeviceEnvironmentSchema,
|
||||
type DevicePlatform,
|
||||
DevicePlatformSchema,
|
||||
type DeviceToken,
|
||||
type DeviceTokenIdParam,
|
||||
DeviceTokenIdParamSchema,
|
||||
DeviceTokenSchema,
|
||||
type RegisterDeviceToken,
|
||||
type RegisterDeviceTokenResponse,
|
||||
RegisterDeviceTokenResponseSchema,
|
||||
RegisterDeviceTokenSchema,
|
||||
type SendBulkPushNotification,
|
||||
SendBulkPushNotificationSchema,
|
||||
type SendBulkPushResponse,
|
||||
SendBulkPushResponseSchema,
|
||||
type SendPushNotification,
|
||||
SendPushNotificationSchema,
|
||||
type SendPushResponse,
|
||||
SendPushResponseSchema,
|
||||
type UnregisterDeviceTokenResponse,
|
||||
UnregisterDeviceTokenResponseSchema,
|
||||
} from "./schemas/push-schemas.js";
|
||||
// Services
|
||||
export {
|
||||
ApnService,
|
||||
createApnService,
|
||||
} from "./services/apn-service/apn-service.js";
|
||||
export type {
|
||||
ApnServiceOptions,
|
||||
PushNotificationFailure,
|
||||
PushNotificationPayload,
|
||||
PushNotificationResult,
|
||||
} from "./services/apn-service/apn-service.types.js";
|
||||
// Transport
|
||||
export {
|
||||
createMemoryApnTransport,
|
||||
MemoryApnTransport,
|
||||
} from "./transport/apn-transport/apn-transport.js";
|
||||
export type {
|
||||
ApnTransport,
|
||||
ApnTransportFactory,
|
||||
ApnTransportNotification,
|
||||
ApnTransportResponse,
|
||||
} from "./transport/apn-transport/apn-transport.types.js";
|
||||
|
||||
// Types
|
||||
export type {
|
||||
NotificationsPluginEvents,
|
||||
NotificationsRouteEvent,
|
||||
NotificationsRoutesConfig,
|
||||
PushRoutesConfig,
|
||||
} from "./types/types.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
getNotificationsInstruments,
|
||||
type NotificationsInstruments,
|
||||
resetNotificationsInstruments,
|
||||
} from "./instruments.js";
|
||||
@@ -0,0 +1,85 @@
|
||||
# `otel/instruments`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
OpenTelemetry instrument bundle for `@suaveplan/api-plugin-notifications`.
|
||||
The module exposes a lazy, memoised factory that resolves a meter
|
||||
against the current global `MeterProvider` on first call and caches the
|
||||
resulting `Counter` instances for the lifetime of the process. A
|
||||
companion `resetNotificationsInstruments()` clears the cache so tests
|
||||
can install a fresh in-memory `MeterProvider` and re-resolve the meter
|
||||
against it deterministically.
|
||||
|
||||
## Features
|
||||
|
||||
- Six counters covering the entire production surface: notification
|
||||
create / read / delete, push send / fail, and device-token registry.
|
||||
- Single meter per process — the meter is created with the package
|
||||
name, so traces grouped by `service.namespace` keep notifications
|
||||
metrics separable from sibling plugins.
|
||||
- Reset helper for tests — the test suite installs a fresh
|
||||
`InMemoryMetricExporter` per file, resets the cache, and reads back
|
||||
cumulative metrics through `flushTestMetrics()` from
|
||||
`tests/setup/polyfills.ts`.
|
||||
- Counter attributes are caller-supplied. The route layer passes
|
||||
`{ platform: "ios" }` to `pushSentTotal.add(1, attrs)` and
|
||||
`{ reason: "BadDeviceToken" }` to `pushFailedTotal.add(1, attrs)`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getNotificationsInstruments } from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
const inst = getNotificationsInstruments();
|
||||
inst.notificationsCreatedTotal.add(1);
|
||||
inst.pushSentTotal.add(1, { platform: "ios" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
For test code that installs a fresh provider, reset the cache so the
|
||||
next call rebinds:
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
getNotificationsInstruments,
|
||||
resetNotificationsInstruments,
|
||||
} from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
|
||||
const provider = new MeterProvider();
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
resetNotificationsInstruments();
|
||||
|
||||
const inst = getNotificationsInstruments();
|
||||
inst.pushFailedTotal.add(1, { reason: "BadDeviceToken" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NotificationsInstruments` — readonly interface with six `Counter`
|
||||
fields.
|
||||
- `getNotificationsInstruments(): NotificationsInstruments` — lazy
|
||||
memoised factory. Idempotent within a session.
|
||||
- `resetNotificationsInstruments(): void` — drop the cache so the next
|
||||
factory call rebinds against the current global provider.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The lazy-bind pattern is required because the meter resolution copies
|
||||
the `MeterProvider` reference at meter-create time. If the bundle were
|
||||
created at module load, every test in the suite would share one meter
|
||||
bound to whatever provider happened to be installed first, defeating
|
||||
isolation. The cache resets between tests via the
|
||||
`afterEach(resetNotificationsInstruments)` hook in
|
||||
`instruments.test.ts`.
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
|
||||
import {
|
||||
getNotificationsInstruments,
|
||||
resetNotificationsInstruments,
|
||||
} from "./instruments.js";
|
||||
|
||||
afterEach(() => {
|
||||
resetNotificationsInstruments();
|
||||
});
|
||||
|
||||
describe("getNotificationsInstruments", () => {
|
||||
it("returns the same bundle on repeated calls", { timeout: 5000 }, () => {
|
||||
resetNotificationsInstruments();
|
||||
const a = getNotificationsInstruments();
|
||||
const b = getNotificationsInstruments();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("exposes six named counters", { timeout: 5000 }, () => {
|
||||
resetNotificationsInstruments();
|
||||
const inst = getNotificationsInstruments();
|
||||
expect(inst.notificationsCreatedTotal).toBeDefined();
|
||||
expect(inst.notificationsReadTotal).toBeDefined();
|
||||
expect(inst.notificationsDeletedTotal).toBeDefined();
|
||||
expect(inst.pushSentTotal).toBeDefined();
|
||||
expect(inst.pushFailedTotal).toBeDefined();
|
||||
expect(inst.deviceTokensRegisteredTotal).toBeDefined();
|
||||
});
|
||||
|
||||
it("exposes dispatcher counters", { timeout: 5000 }, () => {
|
||||
resetNotificationsInstruments();
|
||||
const inst = getNotificationsInstruments();
|
||||
expect(inst.notificationsDispatchedTotal).toBeDefined();
|
||||
expect(inst.notificationsDispatchFailedTotal).toBeDefined();
|
||||
});
|
||||
|
||||
it("dispatcher counters emit on flush", { timeout: 30000 }, async () => {
|
||||
resetNotificationsInstruments();
|
||||
const inst = getNotificationsInstruments();
|
||||
inst.notificationsDispatchedTotal.add(1, { channel: "apn" });
|
||||
inst.notificationsDispatchFailedTotal.add(1, {
|
||||
channel: "email",
|
||||
reason: "transport_error",
|
||||
});
|
||||
const snapshots = await flushTestMetrics();
|
||||
const names = snapshots.flatMap((s) =>
|
||||
s.scopeMetrics.flatMap((sm) =>
|
||||
sm.metrics.map((m) => m.descriptor.name)
|
||||
)
|
||||
);
|
||||
expect(names).toContain("notifications.dispatched_total");
|
||||
expect(names).toContain("notifications.dispatch_failed_total");
|
||||
});
|
||||
|
||||
it("emits notification metric names on flush", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
resetNotificationsInstruments();
|
||||
const inst = getNotificationsInstruments();
|
||||
inst.notificationsCreatedTotal.add(1, { type: "friend_request" });
|
||||
inst.notificationsReadTotal.add(2);
|
||||
inst.notificationsDeletedTotal.add(1);
|
||||
inst.pushSentTotal.add(3, { platform: "ios" });
|
||||
inst.pushFailedTotal.add(1, { reason: "BadDeviceToken" });
|
||||
inst.deviceTokensRegisteredTotal.add(1, { platform: "ios" });
|
||||
const snapshots = await flushTestMetrics();
|
||||
const names = snapshots.flatMap((s) =>
|
||||
s.scopeMetrics.flatMap((sm) =>
|
||||
sm.metrics.map((m) => m.descriptor.name)
|
||||
)
|
||||
);
|
||||
expect(names).toContain("notifications.created_total");
|
||||
expect(names).toContain("notifications.read_total");
|
||||
expect(names).toContain("notifications.deleted_total");
|
||||
expect(names).toContain("notifications.push_sent_total");
|
||||
expect(names).toContain("notifications.push_failed_total");
|
||||
expect(names).toContain("notifications.device_tokens_registered_total");
|
||||
});
|
||||
|
||||
it("resetNotificationsInstruments rebinds against a fresh meter", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetNotificationsInstruments();
|
||||
const a = getNotificationsInstruments();
|
||||
resetNotificationsInstruments();
|
||||
const b = getNotificationsInstruments();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { type Counter, metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Bundle of OpenTelemetry instruments emitted by the notifications
|
||||
* plugin.
|
||||
*
|
||||
* - `notificationsCreatedTotal` — incremented on every successful
|
||||
* `POST /notifications`.
|
||||
* - `notificationsReadTotal` — incremented on every successful
|
||||
* `POST /notifications/:id/read` or `markAllAsRead`.
|
||||
* - `notificationsDeletedTotal` — incremented on every successful
|
||||
* `DELETE /notifications/:id`.
|
||||
* - `pushSentTotal{platform}` — incremented per successful APN
|
||||
* delivery, labelled by platform.
|
||||
* - `pushFailedTotal{reason}` — incremented per failed APN delivery,
|
||||
* labelled by reason (e.g. `BadDeviceToken`, `transport_error`).
|
||||
* - `deviceTokensRegisteredTotal` — incremented every time a fresh
|
||||
* device-token row is created via `POST /push/tokens`.
|
||||
*/
|
||||
export interface NotificationsInstruments {
|
||||
readonly notificationsCreatedTotal: Counter;
|
||||
readonly notificationsReadTotal: Counter;
|
||||
readonly notificationsDeletedTotal: Counter;
|
||||
readonly pushSentTotal: Counter;
|
||||
readonly pushFailedTotal: Counter;
|
||||
readonly deviceTokensRegisteredTotal: Counter;
|
||||
readonly notificationsDispatchedTotal: Counter;
|
||||
readonly notificationsDispatchFailedTotal: Counter;
|
||||
}
|
||||
|
||||
let cached: NotificationsInstruments | undefined;
|
||||
|
||||
/**
|
||||
* Lazily build (and cache) the notifications instruments bundle against
|
||||
* the current global `MeterProvider`. Call
|
||||
* {@link resetNotificationsInstruments} in test setup to force a
|
||||
* re-bind against a freshly-installed provider.
|
||||
*/
|
||||
export function getNotificationsInstruments(): NotificationsInstruments {
|
||||
if (cached !== undefined) return cached;
|
||||
const meter = metrics.getMeter("@suaveplan/api-plugin-notifications");
|
||||
cached = {
|
||||
notificationsCreatedTotal: meter.createCounter(
|
||||
"notifications.created_total",
|
||||
{
|
||||
description:
|
||||
"Total notifications successfully created via POST /notifications",
|
||||
}
|
||||
),
|
||||
notificationsReadTotal: meter.createCounter(
|
||||
"notifications.read_total",
|
||||
{
|
||||
description:
|
||||
"Total notifications transitioned to read state via mark-read endpoints",
|
||||
}
|
||||
),
|
||||
notificationsDeletedTotal: meter.createCounter(
|
||||
"notifications.deleted_total",
|
||||
{
|
||||
description:
|
||||
"Total notifications deleted via DELETE /notifications/:id",
|
||||
}
|
||||
),
|
||||
pushSentTotal: meter.createCounter("notifications.push_sent_total", {
|
||||
description:
|
||||
"Total push notifications delivered through the APN transport, partitioned by platform",
|
||||
}),
|
||||
pushFailedTotal: meter.createCounter(
|
||||
"notifications.push_failed_total",
|
||||
{
|
||||
description:
|
||||
"Total push notifications that failed to deliver, partitioned by reason",
|
||||
}
|
||||
),
|
||||
deviceTokensRegisteredTotal: meter.createCounter(
|
||||
"notifications.device_tokens_registered_total",
|
||||
{
|
||||
description:
|
||||
"Total fresh device-token rows created via POST /push/tokens",
|
||||
}
|
||||
),
|
||||
notificationsDispatchedTotal: meter.createCounter(
|
||||
"notifications.dispatched_total",
|
||||
{
|
||||
description:
|
||||
"Total notifications successfully dispatched through NotificationDispatcher, partitioned by channel",
|
||||
}
|
||||
),
|
||||
notificationsDispatchFailedTotal: meter.createCounter(
|
||||
"notifications.dispatch_failed_total",
|
||||
{
|
||||
description:
|
||||
"Total notifications that failed dispatch through NotificationDispatcher, partitioned by channel and reason",
|
||||
}
|
||||
),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached instrument bundle so the next
|
||||
* {@link getNotificationsInstruments} call rebinds against the global
|
||||
* `MeterProvider` in effect at that point. Tests call this after
|
||||
* installing a fresh `MeterProvider`.
|
||||
*/
|
||||
export function resetNotificationsInstruments(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
# `plugin/plugin`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Plugin descriptor factory for `@suaveplan/api-plugin-notifications`.
|
||||
Composes everything in the package — Zod-validated options,
|
||||
SurrealDB-backed `NotificationRepository` and
|
||||
`DeviceTokenRepository`, the optional `ApnService`, the two route
|
||||
modules, and the OTel instrument bundle — into a single
|
||||
`PluginDescriptor<NotificationsPluginConfig>` that hosts mount via
|
||||
`@suaveplan/api-plugin`'s standard lifecycle.
|
||||
|
||||
## Features
|
||||
|
||||
- Boundary Zod validation — invalid options throw
|
||||
`NotificationsPluginError(INVALID_CONFIG)` with the `ZodError`
|
||||
attached as `cause`.
|
||||
- Two-phase lifecycle: `onInit` registers repositories and the APN
|
||||
service in the host's `ServiceRegistry`; `onReady` mounts both
|
||||
route trees and refuses double-mount with
|
||||
`ROUTE_PREFIX_COLLISION`.
|
||||
- Event-bus integration — emits `notifications:initialized` and
|
||||
`notifications:ready` on the shared `ctx.eventBus` so siblings
|
||||
observe lifecycle transitions. Route-level events flow through a
|
||||
single `forwarder` callback that the route modules call via
|
||||
`emitEvent`.
|
||||
- Optional APN — `enablePush: false` (default) skips APN
|
||||
construction; `enablePush: true` without `config.apn` throws
|
||||
`APN_CONFIG_MISSING` at boot.
|
||||
- Graceful close — `onClose` shuts down the APN transport (if any)
|
||||
and clears the repository references so the GC can collect them.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createNotificationsPlugin } from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
await api.loadPlugin(createNotificationsPlugin());
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Enable push delivery with a token-auth APN config and a custom mount
|
||||
prefix:
|
||||
|
||||
```ts
|
||||
await api.loadPlugin(
|
||||
createNotificationsPlugin({
|
||||
basePath: "/v1/notifications",
|
||||
pushBasePath: "/v1/push",
|
||||
enablePush: true,
|
||||
apn: {
|
||||
token: {
|
||||
key: "/keys/AuthKey_ABC.p8",
|
||||
keyId: "ABC",
|
||||
teamId: "TEAM",
|
||||
},
|
||||
defaultTopic: "com.example.app",
|
||||
production: true,
|
||||
},
|
||||
maxBulkRecipients: 500,
|
||||
autoCleanOldDays: 14,
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createNotificationsPlugin(options?: NotificationsPluginConfigInput)` →
|
||||
`PluginDescriptor<NotificationsPluginConfig>`
|
||||
- Lifecycle hooks: `onInit(ctx)`, `onReady(ctx)`, `onClose()`.
|
||||
- Service slots registered: `notificationRepository`,
|
||||
`deviceTokenRepository`, `apnService` (only when `enablePush`).
|
||||
- Event-bus emissions: `notifications:initialized`,
|
||||
`notifications:ready`, plus every event in `NotificationsPluginEvents`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The plugin name is `"notifications"`. Dependencies are
|
||||
`["database"]` — `onInit` will refuse to boot without an
|
||||
`@suaveplan/api-plugin-database`-registered `db` slot. The plugin
|
||||
calls `ctx.eventBus.emit(event.type, event.payload)` from a single
|
||||
`forwarder` callback rather than dragging the `eventBus` reference
|
||||
into every route handler. This matches the api-plugin-events pattern
|
||||
and means route handlers stay testable in isolation.
|
||||
@@ -0,0 +1,517 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import { InMemoryDeliveryEventStore } from "@suaveplan/delivery-tracking";
|
||||
import { createDummyTransport, EmailService } from "@suaveplan/emailer";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NoopIdempotencyAdapter } from "../dispatcher/idempotency-adapter/idempotency-adapter.js";
|
||||
import {
|
||||
NOTIFICATIONS_PLUGIN_CODES,
|
||||
NotificationsPluginError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type { NotificationsPluginConfig } from "../schemas/options.js";
|
||||
import { createNotificationsPlugin } from "./plugin.js";
|
||||
|
||||
async function makeEmailService(): Promise<EmailService> {
|
||||
const e = new EmailService({
|
||||
transport: createDummyTransport(),
|
||||
transportOptions: {},
|
||||
});
|
||||
await e.initialize();
|
||||
return e;
|
||||
}
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
function fakeDb(): SurrealAdapter {
|
||||
return {
|
||||
async query() {
|
||||
return [];
|
||||
},
|
||||
} as unknown as SurrealAdapter;
|
||||
}
|
||||
|
||||
function buildCtx<TConfig>(
|
||||
config: TConfig,
|
||||
services: Map<string, unknown> = new Map(),
|
||||
bus: EventBus = new EventBus()
|
||||
) {
|
||||
return createPluginContext<TConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: bus,
|
||||
signal: new AbortController().signal,
|
||||
config,
|
||||
services,
|
||||
});
|
||||
}
|
||||
|
||||
describe("createNotificationsPlugin — descriptor", () => {
|
||||
it("returns descriptor with defaults", { timeout: 5000 }, () => {
|
||||
const plugin = createNotificationsPlugin({});
|
||||
expect(plugin.name).toBe("notifications");
|
||||
expect(plugin.dependencies).toEqual(["database"]);
|
||||
const def = plugin.defaults as NotificationsPluginConfig;
|
||||
expect(def.basePath).toBe("/notifications");
|
||||
expect(def.pushBasePath).toBe("/push");
|
||||
expect(def.enablePush).toBe(false);
|
||||
});
|
||||
|
||||
it("no-arg call uses every default", { timeout: 5000 }, () => {
|
||||
const plugin = createNotificationsPlugin();
|
||||
const def = plugin.defaults as NotificationsPluginConfig;
|
||||
expect(def.autoCleanOldDays).toBe(30);
|
||||
expect(def.maxBulkRecipients).toBe(1000);
|
||||
});
|
||||
|
||||
it("throws INVALID_CONFIG on bad options", { timeout: 5000 }, () => {
|
||||
try {
|
||||
createNotificationsPlugin({ basePath: "" } as never);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(NotificationsPluginError);
|
||||
expect((err as NotificationsPluginError).code).toBe(
|
||||
NOTIFICATIONS_PLUGIN_CODES.INVALID_CONFIG
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createNotificationsPlugin — lifecycle", () => {
|
||||
it("onInit registers repos", { timeout: 5000 }, async () => {
|
||||
const plugin = createNotificationsPlugin({});
|
||||
const services = new Map<string, unknown>([["db", fakeDb()]]);
|
||||
const ctx = buildCtx(
|
||||
plugin.defaults as NotificationsPluginConfig,
|
||||
services
|
||||
);
|
||||
await plugin.onInit?.(ctx);
|
||||
expect(ctx.getService("notificationRepository")).toBeDefined();
|
||||
expect(ctx.getService("deviceTokenRepository")).toBeDefined();
|
||||
});
|
||||
|
||||
it("onInit throws SERVICE_NOT_AVAILABLE without db", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createNotificationsPlugin({});
|
||||
const ctx = buildCtx(plugin.defaults as NotificationsPluginConfig);
|
||||
await expect(plugin.onInit?.(ctx)).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.SERVICE_NOT_AVAILABLE,
|
||||
});
|
||||
});
|
||||
|
||||
it("onInit throws APN_CONFIG_MISSING when enablePush without apn", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createNotificationsPlugin({ enablePush: true });
|
||||
const ctx = buildCtx(
|
||||
plugin.defaults as NotificationsPluginConfig,
|
||||
new Map([["db", fakeDb()]])
|
||||
);
|
||||
await expect(plugin.onInit?.(ctx)).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.APN_CONFIG_MISSING,
|
||||
});
|
||||
});
|
||||
|
||||
it("onInit constructs apnService when enablePush+apn", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createNotificationsPlugin({
|
||||
enablePush: true,
|
||||
apn: {
|
||||
token: { key: "k", keyId: "i", teamId: "t" },
|
||||
defaultTopic: "com.example.app",
|
||||
},
|
||||
});
|
||||
const ctx = buildCtx(
|
||||
plugin.defaults as NotificationsPluginConfig,
|
||||
new Map([["db", fakeDb()]])
|
||||
);
|
||||
await plugin.onInit?.(ctx);
|
||||
expect(ctx.getService("apnService")).toBeDefined();
|
||||
});
|
||||
|
||||
it("onInit emits notifications:initialized", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createNotificationsPlugin();
|
||||
const bus = new EventBus();
|
||||
const seen: string[] = [];
|
||||
bus.on("notifications:initialized", () => seen.push("init"));
|
||||
const ctx = buildCtx(
|
||||
plugin.defaults as NotificationsPluginConfig,
|
||||
new Map([["db", fakeDb()]]),
|
||||
bus
|
||||
);
|
||||
await plugin.onInit?.(ctx);
|
||||
expect(seen).toContain("init");
|
||||
});
|
||||
|
||||
it("onReady mounts both route trees", { timeout: 10000 }, async () => {
|
||||
const plugin = createNotificationsPlugin();
|
||||
const app = new OpenAPIHono();
|
||||
const ctx = buildCtx(
|
||||
plugin.defaults as NotificationsPluginConfig,
|
||||
new Map<string, unknown>([
|
||||
["db", fakeDb()],
|
||||
["app", app],
|
||||
])
|
||||
);
|
||||
await plugin.onInit?.(ctx);
|
||||
await plugin.onReady?.(ctx);
|
||||
const paths = (
|
||||
app as unknown as { routes: ReadonlyArray<{ path: string }> }
|
||||
).routes.map((r) => r.path);
|
||||
expect(paths.some((p) => p.startsWith("/notifications"))).toBe(true);
|
||||
expect(paths.some((p) => p.startsWith("/push"))).toBe(true);
|
||||
});
|
||||
|
||||
it("onReady throws ROUTE_PREFIX_COLLISION when only push prefix collides", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
const plugin = createNotificationsPlugin();
|
||||
const app = new OpenAPIHono();
|
||||
app.get("/push/preexisting", (c) => c.text("hi"));
|
||||
const ctx = buildCtx(
|
||||
plugin.defaults as NotificationsPluginConfig,
|
||||
new Map<string, unknown>([
|
||||
["db", fakeDb()],
|
||||
["app", app],
|
||||
])
|
||||
);
|
||||
await plugin.onInit?.(ctx);
|
||||
await expect(plugin.onReady?.(ctx)).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.ROUTE_PREFIX_COLLISION,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwarder forwards route events onto the eventBus", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
const plugin = createNotificationsPlugin();
|
||||
const app = new OpenAPIHono<{
|
||||
Variables: { user: { id: string } | null };
|
||||
}>();
|
||||
app.use(async (c, next) => {
|
||||
c.set("user", { id: "user:1" });
|
||||
await next();
|
||||
});
|
||||
const bus = new EventBus();
|
||||
const seen: string[] = [];
|
||||
bus.on("notification:created", () => seen.push("created"));
|
||||
const ctx = buildCtx(
|
||||
plugin.defaults as NotificationsPluginConfig,
|
||||
new Map<string, unknown>([
|
||||
[
|
||||
"db",
|
||||
{
|
||||
async query() {
|
||||
return [
|
||||
{
|
||||
id: "notification:1",
|
||||
type: "system",
|
||||
recipient: "user:1",
|
||||
content: "x",
|
||||
read: false,
|
||||
dismissed: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
];
|
||||
},
|
||||
} as unknown as SurrealAdapter,
|
||||
],
|
||||
["app", app],
|
||||
]),
|
||||
bus
|
||||
);
|
||||
await plugin.onInit?.(ctx);
|
||||
await plugin.onReady?.(ctx);
|
||||
const res = await app.request("/notifications", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "system",
|
||||
recipient: "user:1",
|
||||
content: "x",
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(seen).toContain("created");
|
||||
});
|
||||
|
||||
it("onReady throws ROUTE_PREFIX_COLLISION on double-mount", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
const plugin = createNotificationsPlugin();
|
||||
const app = new OpenAPIHono();
|
||||
const ctx = buildCtx(
|
||||
plugin.defaults as NotificationsPluginConfig,
|
||||
new Map<string, unknown>([
|
||||
["db", fakeDb()],
|
||||
["app", app],
|
||||
])
|
||||
);
|
||||
await plugin.onInit?.(ctx);
|
||||
await plugin.onReady?.(ctx);
|
||||
await expect(plugin.onReady?.(ctx)).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.ROUTE_PREFIX_COLLISION,
|
||||
});
|
||||
});
|
||||
|
||||
it("onReady throws SERVICE_NOT_AVAILABLE before onInit", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createNotificationsPlugin();
|
||||
const app = new OpenAPIHono();
|
||||
const ctx = buildCtx(
|
||||
plugin.defaults as NotificationsPluginConfig,
|
||||
new Map<string, unknown>([["app", app]])
|
||||
);
|
||||
await expect(plugin.onReady?.(ctx)).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.SERVICE_NOT_AVAILABLE,
|
||||
});
|
||||
});
|
||||
|
||||
it("onReady throws SERVICE_NOT_AVAILABLE without app", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createNotificationsPlugin();
|
||||
const ctx = buildCtx(
|
||||
plugin.defaults as NotificationsPluginConfig,
|
||||
new Map([["db", fakeDb()]])
|
||||
);
|
||||
await plugin.onInit?.(ctx);
|
||||
await expect(plugin.onReady?.(ctx)).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.SERVICE_NOT_AVAILABLE,
|
||||
});
|
||||
});
|
||||
|
||||
it("onClose clears state and shuts down APN", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createNotificationsPlugin({
|
||||
enablePush: true,
|
||||
apn: {
|
||||
token: { key: "k", keyId: "i", teamId: "t" },
|
||||
defaultTopic: "com.example.app",
|
||||
},
|
||||
});
|
||||
const ctx = buildCtx(
|
||||
plugin.defaults as NotificationsPluginConfig,
|
||||
new Map([["db", fakeDb()]])
|
||||
);
|
||||
await plugin.onInit?.(ctx);
|
||||
await plugin.onClose?.(ctx);
|
||||
// After close, onReady should refuse to mount.
|
||||
const app = new OpenAPIHono();
|
||||
const services = new Map<string, unknown>([
|
||||
["db", fakeDb()],
|
||||
["app", app],
|
||||
]);
|
||||
const ctx2 = buildCtx(
|
||||
plugin.defaults as NotificationsPluginConfig,
|
||||
services
|
||||
);
|
||||
await expect(plugin.onReady?.(ctx2)).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.SERVICE_NOT_AVAILABLE,
|
||||
});
|
||||
});
|
||||
|
||||
it("tolerates an app without a `routes` array on the collision check", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
const plugin = createNotificationsPlugin();
|
||||
// Mock app exposing route() but no `routes` field, so the
|
||||
// Array.isArray(existing) branch resolves to false.
|
||||
const fakeApp = {
|
||||
route: () => undefined,
|
||||
};
|
||||
const ctx = buildCtx(
|
||||
plugin.defaults as NotificationsPluginConfig,
|
||||
new Map<string, unknown>([
|
||||
["db", fakeDb()],
|
||||
["app", fakeApp],
|
||||
])
|
||||
);
|
||||
await plugin.onInit?.(ctx);
|
||||
await plugin.onReady?.(ctx);
|
||||
// No throw.
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("emits notifications:ready", { timeout: 10000 }, async () => {
|
||||
const plugin = createNotificationsPlugin();
|
||||
const bus = new EventBus();
|
||||
const seen: string[] = [];
|
||||
bus.on("notifications:ready", () => seen.push("ready"));
|
||||
const app = new OpenAPIHono();
|
||||
const ctx = buildCtx(
|
||||
plugin.defaults as NotificationsPluginConfig,
|
||||
new Map<string, unknown>([
|
||||
["db", fakeDb()],
|
||||
["app", app],
|
||||
]),
|
||||
bus
|
||||
);
|
||||
await plugin.onInit?.(ctx);
|
||||
await plugin.onReady?.(ctx);
|
||||
expect(seen).toContain("ready");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createNotificationsPlugin — dispatcher wiring", () => {
|
||||
it("onInit registers emailService when injected", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
const email = await makeEmailService();
|
||||
const plugin = createNotificationsPlugin({}, { emailService: email });
|
||||
const ctx = buildCtx(
|
||||
plugin.defaults as NotificationsPluginConfig,
|
||||
new Map<string, unknown>([["db", fakeDb()]])
|
||||
);
|
||||
await plugin.onInit?.(ctx);
|
||||
expect(ctx.getService("emailService")).toBe(email);
|
||||
});
|
||||
|
||||
it("onInit registers notificationDispatcher when enableDispatcher is on", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
const plugin = createNotificationsPlugin({
|
||||
enableDispatcher: true,
|
||||
enablePush: true,
|
||||
apn: {
|
||||
token: { key: "k", keyId: "i", teamId: "t" },
|
||||
defaultTopic: "com.example.app",
|
||||
},
|
||||
});
|
||||
const ctx = buildCtx(
|
||||
plugin.defaults as NotificationsPluginConfig,
|
||||
new Map<string, unknown>([["db", fakeDb()]])
|
||||
);
|
||||
await plugin.onInit?.(ctx);
|
||||
const dispatcher = ctx.getService("notificationDispatcher");
|
||||
expect(dispatcher).toBeDefined();
|
||||
});
|
||||
|
||||
it("onInit registers dispatcher with email channel only", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
const email = await makeEmailService();
|
||||
const plugin = createNotificationsPlugin(
|
||||
{
|
||||
enableDispatcher: true,
|
||||
defaultChannel: "email",
|
||||
},
|
||||
{ emailService: email }
|
||||
);
|
||||
const ctx = buildCtx(
|
||||
plugin.defaults as NotificationsPluginConfig,
|
||||
new Map<string, unknown>([["db", fakeDb()]])
|
||||
);
|
||||
await plugin.onInit?.(ctx);
|
||||
const dispatcher = ctx.getService("notificationDispatcher");
|
||||
expect(dispatcher).toBeDefined();
|
||||
const r = await dispatcher?.dispatch({
|
||||
recipient: "u@x",
|
||||
body: "hi",
|
||||
channel: "email",
|
||||
});
|
||||
expect(r?.channel).toBe("email");
|
||||
});
|
||||
|
||||
it("onInit refuses dispatcher with zero channels", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
const plugin = createNotificationsPlugin({
|
||||
enableDispatcher: true,
|
||||
enablePush: false,
|
||||
});
|
||||
const ctx = buildCtx(
|
||||
plugin.defaults as NotificationsPluginConfig,
|
||||
new Map<string, unknown>([["db", fakeDb()]])
|
||||
);
|
||||
await expect(plugin.onInit?.(ctx)).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.DISPATCH_CHANNEL_UNAVAILABLE,
|
||||
});
|
||||
});
|
||||
|
||||
it("onClose nulls the dispatcher reference", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
const plugin = createNotificationsPlugin({
|
||||
enableDispatcher: true,
|
||||
enablePush: true,
|
||||
apn: {
|
||||
token: { key: "k", keyId: "i", teamId: "t" },
|
||||
defaultTopic: "com.example.app",
|
||||
},
|
||||
});
|
||||
const ctx = buildCtx(
|
||||
plugin.defaults as NotificationsPluginConfig,
|
||||
new Map<string, unknown>([["db", fakeDb()]])
|
||||
);
|
||||
await plugin.onInit?.(ctx);
|
||||
await plugin.onClose?.(ctx);
|
||||
// After close, the slot still resolves to the registered
|
||||
// reference (registry doesn't auto-clear) but the
|
||||
// dispatcher reference inside the closure is nulled — we
|
||||
// exercise the close branch primarily.
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("onInit constructs apnService without a defaultTopic", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
const plugin = createNotificationsPlugin({
|
||||
enableDispatcher: true,
|
||||
enablePush: true,
|
||||
apn: { token: { key: "k", keyId: "i", teamId: "t" } },
|
||||
});
|
||||
const ctx = buildCtx(
|
||||
plugin.defaults as NotificationsPluginConfig,
|
||||
new Map<string, unknown>([["db", fakeDb()]])
|
||||
);
|
||||
await plugin.onInit?.(ctx);
|
||||
expect(ctx.getService("notificationDispatcher")).toBeDefined();
|
||||
});
|
||||
|
||||
it("onInit forwards deliveryStore + idempotency into the dispatcher", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
const store = new InMemoryDeliveryEventStore();
|
||||
const idem = new NoopIdempotencyAdapter();
|
||||
const email = await makeEmailService();
|
||||
const plugin = createNotificationsPlugin(
|
||||
{
|
||||
enableDispatcher: true,
|
||||
defaultChannel: "email",
|
||||
},
|
||||
{
|
||||
emailService: email,
|
||||
deliveryStore: store,
|
||||
idempotency: idem,
|
||||
}
|
||||
);
|
||||
const ctx = buildCtx(
|
||||
plugin.defaults as NotificationsPluginConfig,
|
||||
new Map<string, unknown>([["db", fakeDb()]])
|
||||
);
|
||||
await plugin.onInit?.(ctx);
|
||||
const dispatcher = ctx.getService("notificationDispatcher");
|
||||
expect(dispatcher).toBeDefined();
|
||||
const r = await dispatcher?.dispatch({
|
||||
recipient: "u@x",
|
||||
body: "hi",
|
||||
channel: "email",
|
||||
});
|
||||
expect(r?.channel).toBe("email");
|
||||
const events = await store.getByMessageId(r?.messageId ?? "");
|
||||
expect(events).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import {
|
||||
definePlugin,
|
||||
type PluginContext,
|
||||
type PluginDescriptor,
|
||||
} from "@suaveplan/api-plugin";
|
||||
import "../services/registry-augmentation.js";
|
||||
import type { DeliveryEventStore } from "@suaveplan/delivery-tracking";
|
||||
import type { EmailService } from "@suaveplan/emailer";
|
||||
import type { DeliveryChannel } from "@suaveplan/notification-channel";
|
||||
import { ApnDeliveryChannel } from "../channels/apn-channel/apn-channel.js";
|
||||
import { EmailDeliveryChannel } from "../channels/email-channel/email-channel.js";
|
||||
import type { DispatcherIdempotencyAdapter } from "../dispatcher/idempotency-adapter/idempotency-adapter.js";
|
||||
import { NotificationDispatcher } from "../dispatcher/notification-dispatcher/notification-dispatcher.js";
|
||||
import {
|
||||
NOTIFICATIONS_PLUGIN_CODES,
|
||||
NotificationsPluginError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { DeviceTokenRepository } from "../repositories/device-token-repository/device-token-repository.js";
|
||||
import { NotificationRepository } from "../repositories/notification-repository/notification-repository.js";
|
||||
import { createNotificationsRoutes } from "../routes/notifications-routes/notifications-routes.js";
|
||||
import { createPushRoutes } from "../routes/push-routes/push-routes.js";
|
||||
import {
|
||||
type NotificationsPluginConfig,
|
||||
type NotificationsPluginConfigInput,
|
||||
NotificationsPluginOptionsSchema,
|
||||
} from "../schemas/options.js";
|
||||
import { ApnService } from "../services/apn-service/apn-service.js";
|
||||
import type { NotificationsRouteEvent } from "../types/types.js";
|
||||
|
||||
interface PluginRuntime {
|
||||
notificationRepository: NotificationRepository | null;
|
||||
deviceTokenRepository: DeviceTokenRepository | null;
|
||||
apnService: ApnService | null;
|
||||
notificationDispatcher: NotificationDispatcher | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional host-injected services. These are runtime objects (not
|
||||
* config), so they live outside the Zod-validated config bundle.
|
||||
*
|
||||
* Suppliers wire `emailService` to opt the dispatcher into email
|
||||
* delivery; `deliveryStore` and `idempotency` are forwarded straight
|
||||
* into the dispatcher.
|
||||
*/
|
||||
export interface NotificationsPluginServices {
|
||||
/** Inject to enable the email channel on the dispatcher. */
|
||||
readonly emailService?: EmailService;
|
||||
/** Forwarded to the dispatcher for per-dispatch event recording. */
|
||||
readonly deliveryStore?: DeliveryEventStore;
|
||||
/** Forwarded to the dispatcher for per-call dedup. */
|
||||
readonly idempotency?: DispatcherIdempotencyAdapter;
|
||||
}
|
||||
|
||||
/** Build the plugin descriptor for `@suaveplan/api-plugin-notifications`. */
|
||||
export function createNotificationsPlugin(
|
||||
options: NotificationsPluginConfigInput = {},
|
||||
services: NotificationsPluginServices = {}
|
||||
): PluginDescriptor<NotificationsPluginConfig> {
|
||||
const parsed = NotificationsPluginOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.INVALID_CONFIG,
|
||||
"createNotificationsPlugin received invalid options",
|
||||
{ cause: parsed.error, context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const config = parsed.data;
|
||||
const runtime: PluginRuntime = {
|
||||
notificationRepository: null,
|
||||
deviceTokenRepository: null,
|
||||
apnService: null,
|
||||
notificationDispatcher: null,
|
||||
};
|
||||
|
||||
return definePlugin<NotificationsPluginConfig>({
|
||||
name: "notifications",
|
||||
version: "0.1.0",
|
||||
description:
|
||||
"Notifications plugin: SurrealDB-backed repos, /notifications + /push OpenAPI route groups, injectable APN transport, multi-channel NotificationDispatcher with delivery tracking + optional idempotency, 22 stable error codes, 8 OTel counters.",
|
||||
dependencies: ["database"],
|
||||
tags: ["notifications", "push", "apn", "dispatcher"],
|
||||
defaults: config,
|
||||
|
||||
onInit: async (ctx) => runOnInit(ctx, runtime, config, services),
|
||||
onReady: async (ctx) => runOnReady(ctx, config, runtime),
|
||||
onClose: async () => runOnClose(runtime),
|
||||
});
|
||||
}
|
||||
|
||||
async function runOnInit(
|
||||
ctx: PluginContext<NotificationsPluginConfig>,
|
||||
runtime: PluginRuntime,
|
||||
config: NotificationsPluginConfig,
|
||||
services: NotificationsPluginServices
|
||||
): Promise<void> {
|
||||
const db = ctx.getService("db");
|
||||
if (db === undefined) {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.SERVICE_NOT_AVAILABLE,
|
||||
"notifications plugin requires the `db` service in ServiceRegistry — ensure @suaveplan/api-plugin-database is loaded first",
|
||||
{ context: { missing: ["db"] } }
|
||||
);
|
||||
}
|
||||
runtime.notificationRepository = new NotificationRepository({ db });
|
||||
runtime.deviceTokenRepository = new DeviceTokenRepository({ db });
|
||||
ctx.registerService(
|
||||
"notificationRepository",
|
||||
runtime.notificationRepository
|
||||
);
|
||||
ctx.registerService("deviceTokenRepository", runtime.deviceTokenRepository);
|
||||
|
||||
if (config.enablePush) {
|
||||
if (config.apn === undefined) {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.APN_CONFIG_MISSING,
|
||||
"enablePush is true but config.apn is missing"
|
||||
);
|
||||
}
|
||||
runtime.apnService = new ApnService({ config: config.apn });
|
||||
await runtime.apnService.initialize();
|
||||
ctx.registerService("apnService", runtime.apnService);
|
||||
}
|
||||
|
||||
if (services.emailService !== undefined) {
|
||||
ctx.registerService("emailService", services.emailService);
|
||||
}
|
||||
|
||||
if (config.enableDispatcher) {
|
||||
const channels: DeliveryChannel[] = [];
|
||||
if (runtime.apnService !== null) {
|
||||
channels.push(
|
||||
new ApnDeliveryChannel({
|
||||
apnService: runtime.apnService,
|
||||
...(config.apn?.defaultTopic !== undefined
|
||||
? { defaultTopic: config.apn.defaultTopic }
|
||||
: {}),
|
||||
})
|
||||
);
|
||||
}
|
||||
if (services.emailService !== undefined) {
|
||||
channels.push(
|
||||
new EmailDeliveryChannel({
|
||||
emailService: services.emailService,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (channels.length === 0) {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.DISPATCH_CHANNEL_UNAVAILABLE,
|
||||
"enableDispatcher is true but no delivery channels are available (set enablePush, supply services.emailService, or both)"
|
||||
);
|
||||
}
|
||||
runtime.notificationDispatcher = new NotificationDispatcher({
|
||||
channels,
|
||||
defaultChannel: config.defaultChannel,
|
||||
tenantId: config.deliveryTenantId,
|
||||
...(services.deliveryStore !== undefined
|
||||
? { deliveryStore: services.deliveryStore }
|
||||
: {}),
|
||||
...(services.idempotency !== undefined
|
||||
? { idempotency: services.idempotency }
|
||||
: {}),
|
||||
});
|
||||
ctx.registerService(
|
||||
"notificationDispatcher",
|
||||
runtime.notificationDispatcher
|
||||
);
|
||||
}
|
||||
|
||||
ctx.eventBus.emit("notifications:initialized", undefined);
|
||||
}
|
||||
|
||||
async function runOnReady(
|
||||
ctx: PluginContext<NotificationsPluginConfig>,
|
||||
config: NotificationsPluginConfig,
|
||||
runtime: PluginRuntime
|
||||
): Promise<void> {
|
||||
if (
|
||||
runtime.notificationRepository === null ||
|
||||
runtime.deviceTokenRepository === null
|
||||
) {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.SERVICE_NOT_AVAILABLE,
|
||||
"onReady invoked before onInit completed",
|
||||
{ context: { phase: "onReady" } }
|
||||
);
|
||||
}
|
||||
const app = ctx.getService("app");
|
||||
if (app === undefined) {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.SERVICE_NOT_AVAILABLE,
|
||||
"notifications plugin requires the `app` service in ServiceRegistry",
|
||||
{ context: { missing: ["app"] } }
|
||||
);
|
||||
}
|
||||
const existing = (
|
||||
app as unknown as { routes?: ReadonlyArray<{ path: string }> }
|
||||
).routes;
|
||||
if (Array.isArray(existing)) {
|
||||
if (
|
||||
existing.some((r) => r.path.startsWith(`${config.basePath}/`)) ||
|
||||
existing.some((r) => r.path.startsWith(`${config.pushBasePath}/`))
|
||||
) {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.ROUTE_PREFIX_COLLISION,
|
||||
`Route prefix collision on '${config.basePath}' or '${config.pushBasePath}'`,
|
||||
{
|
||||
context: {
|
||||
prefix: config.basePath,
|
||||
pushPrefix: config.pushBasePath,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const forwarder = (event: NotificationsRouteEvent): void => {
|
||||
ctx.eventBus.emit(event.type, event.payload);
|
||||
};
|
||||
|
||||
const notificationsRoutes = createNotificationsRoutes({
|
||||
notificationRepository: runtime.notificationRepository,
|
||||
emitEvent: forwarder,
|
||||
});
|
||||
const pushRoutes = createPushRoutes({
|
||||
deviceTokenRepository: runtime.deviceTokenRepository,
|
||||
apnService: runtime.apnService,
|
||||
maxBulkRecipients: config.maxBulkRecipients,
|
||||
emitEvent: forwarder,
|
||||
});
|
||||
|
||||
app.route(config.basePath, notificationsRoutes);
|
||||
app.route(config.pushBasePath, pushRoutes);
|
||||
ctx.eventBus.emit("notifications:ready", undefined);
|
||||
}
|
||||
|
||||
async function runOnClose(runtime: PluginRuntime): Promise<void> {
|
||||
if (runtime.apnService !== null) {
|
||||
await runtime.apnService.shutdown();
|
||||
runtime.apnService = null;
|
||||
}
|
||||
runtime.notificationDispatcher = null;
|
||||
runtime.notificationRepository = null;
|
||||
runtime.deviceTokenRepository = null;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
# `repositories/device-token-repository`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
SurrealDB-backed repository for the `device_token` table. Owns every
|
||||
SurrealQL statement the plugin issues against device-token rows and
|
||||
pins the placeholder convention to positional `$p0`, `$p1`, … so the
|
||||
`DatabaseAdapter.query(sql, params: readonly unknown[])`
|
||||
surface can rewrite positional arrays into `{p0, p1, …}` records
|
||||
before handing them to the surrealdb.js driver.
|
||||
|
||||
## Features
|
||||
|
||||
- Eight operations: `register`, `findById`, `findByToken`,
|
||||
`findActiveForUser`, `deactivate`, `deactivateByToken`,
|
||||
`touchLastUsed`, `cleanup`.
|
||||
- Duplicate-token detection — the SurrealDB `UNIQUE` index on `token`
|
||||
is honoured at the repository layer with the
|
||||
`DEVICE_TOKEN_DUPLICATE` error code so callers can branch on
|
||||
duplicate registration without grepping driver messages.
|
||||
- Soft-delete semantics — `deactivate` flips `is_active = false` and
|
||||
bumps `updated_at`; `cleanup(daysOld)` actually removes rows that
|
||||
have been inactive for `daysOld` days OR have not been seen in
|
||||
`daysOld` days (whichever).
|
||||
- Last-seen telemetry — `touchLastUsed(token)` updates `last_used` so
|
||||
retention cleanup can prune abandoned devices.
|
||||
- Record-id normalisation — both bare and prefixed ids accepted; the
|
||||
repository normalises on the way down.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { DeviceTokenRepository } from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
const repo = new DeviceTokenRepository({ db });
|
||||
const token = await repo.register("user:42", {
|
||||
deviceToken: "deadbeef…",
|
||||
deviceType: "ios",
|
||||
environment: "sandbox",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Resolve all active tokens for a user, touch on every successful
|
||||
delivery, prune inactive devices weekly:
|
||||
|
||||
```ts
|
||||
const tokens = await repo.findActiveForUser("user:42");
|
||||
for (const t of tokens) {
|
||||
const res = await apnService.sendNotification({
|
||||
deviceToken: t.token,
|
||||
alert: { title: "Hi" },
|
||||
});
|
||||
if (res.sent === 1) await repo.touchLastUsed(t.token);
|
||||
}
|
||||
const pruned = await repo.cleanup(30);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DeviceTokenRepository(config: { db: SurrealAdapter })`
|
||||
- `IDeviceTokenRepository` — interface (every method documented).
|
||||
- Throws `DEVICE_TOKEN_CREATE_FAILED` on adapter-empty CREATE,
|
||||
`DEVICE_TOKEN_DUPLICATE` on unique-index violation,
|
||||
`DEVICE_TOKEN_NOT_FOUND` for malformed rows, `INVALID_CONFIG` on
|
||||
negative `daysOld`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`cleanup` interpolates the ISO datetime literal directly into the
|
||||
SurrealQL string because SurrealDB does not coerce bound string
|
||||
parameters to `datetime` inside `<` comparisons. The caller is the
|
||||
only producer of `daysOld`, which is gated to non-negative integers
|
||||
at the boundary, so injection is not a concern. Duplicate detection
|
||||
falls back to string matching on the driver's error message because
|
||||
the SurrealDB JavaScript driver does not currently expose error
|
||||
codes; the heuristic is conservative — it requires the message to
|
||||
mention `token`.
|
||||
@@ -0,0 +1,288 @@
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
NOTIFICATIONS_PLUGIN_CODES,
|
||||
NotificationsPluginError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { DeviceTokenRepository } from "./device-token-repository.js";
|
||||
|
||||
interface QueryCall {
|
||||
sql: string;
|
||||
params: readonly unknown[] | undefined;
|
||||
}
|
||||
|
||||
function makeFakeDb(rows: unknown[][]): {
|
||||
db: SurrealAdapter;
|
||||
calls: QueryCall[];
|
||||
} {
|
||||
const calls: QueryCall[] = [];
|
||||
let i = 0;
|
||||
const db = {
|
||||
async query<T>(sql: string, params?: readonly unknown[]) {
|
||||
calls.push({ sql, params });
|
||||
const row = rows[i++] ?? [];
|
||||
return row as T[];
|
||||
},
|
||||
} as unknown as SurrealAdapter;
|
||||
return { db, calls };
|
||||
}
|
||||
|
||||
function throwingDb(error: Error): SurrealAdapter {
|
||||
return {
|
||||
async query() {
|
||||
throw error;
|
||||
},
|
||||
} as unknown as SurrealAdapter;
|
||||
}
|
||||
|
||||
function throwingNonErrorDb(value: unknown): SurrealAdapter {
|
||||
return {
|
||||
async query() {
|
||||
// eslint-disable-next-line no-throw-literal
|
||||
throw value;
|
||||
},
|
||||
} as unknown as SurrealAdapter;
|
||||
}
|
||||
|
||||
const sampleRow = {
|
||||
id: "device_token:1",
|
||||
user_id: "user:1",
|
||||
device_id: "dev1",
|
||||
token: "deadbeef",
|
||||
platform: "ios",
|
||||
environment: "sandbox",
|
||||
is_active: true,
|
||||
last_used: "2026-01-01T00:00:00Z",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
describe("DeviceTokenRepository.register", () => {
|
||||
it("creates a token row", { timeout: 5000 }, async () => {
|
||||
const { db, calls } = makeFakeDb([[sampleRow]]);
|
||||
const repo = new DeviceTokenRepository({ db });
|
||||
const res = await repo.register("user:1", {
|
||||
deviceToken: "deadbeef",
|
||||
deviceId: "dev1",
|
||||
deviceType: "ios",
|
||||
environment: "sandbox",
|
||||
appVersion: "1.0",
|
||||
osVersion: "17",
|
||||
});
|
||||
expect(res.id).toBe("device_token:1");
|
||||
expect(calls[0]?.sql).toContain("CREATE device_token SET");
|
||||
expect(calls[0]?.sql).toContain("user_id = user:1");
|
||||
expect(calls[0]?.sql).toContain("app_version = $p");
|
||||
expect(calls[0]?.sql).toContain("os_version = $p");
|
||||
});
|
||||
|
||||
it("throws DEVICE_TOKEN_DUPLICATE on unique-index violation", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const repo = new DeviceTokenRepository({
|
||||
db: throwingDb(
|
||||
new Error("duplicate key violates UNIQUE index on token")
|
||||
),
|
||||
});
|
||||
await expect(
|
||||
repo.register("user:1", { deviceToken: "abc" })
|
||||
).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.DEVICE_TOKEN_DUPLICATE,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws DEVICE_TOKEN_CREATE_FAILED on generic failure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const repo = new DeviceTokenRepository({
|
||||
db: throwingDb(new Error("network down")),
|
||||
});
|
||||
await expect(
|
||||
repo.register("user:1", { deviceToken: "abc" })
|
||||
).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.DEVICE_TOKEN_CREATE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles non-Error duplicate via String() conversion", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const repo = new DeviceTokenRepository({
|
||||
db: throwingNonErrorDb("token duplicate"),
|
||||
});
|
||||
await expect(
|
||||
repo.register("user:1", { deviceToken: "abc" })
|
||||
).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.DEVICE_TOKEN_DUPLICATE,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles non-Error generic failure via String() conversion", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const repo = new DeviceTokenRepository({
|
||||
db: throwingNonErrorDb("network broke"),
|
||||
});
|
||||
await expect(
|
||||
repo.register("user:1", { deviceToken: "abc" })
|
||||
).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.DEVICE_TOKEN_CREATE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws DEVICE_TOKEN_CREATE_FAILED on empty adapter row", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { db } = makeFakeDb([[]]);
|
||||
const repo = new DeviceTokenRepository({ db });
|
||||
await expect(
|
||||
repo.register("user:1", { deviceToken: "abc" })
|
||||
).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.DEVICE_TOKEN_CREATE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws DEVICE_TOKEN_NOT_FOUND on non-object adapter row", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { db } = makeFakeDb([[42 as unknown]]);
|
||||
const repo = new DeviceTokenRepository({ db });
|
||||
await expect(
|
||||
repo.register("user:1", { deviceToken: "abc" })
|
||||
).rejects.toBeInstanceOf(NotificationsPluginError);
|
||||
});
|
||||
|
||||
it("uses deviceToken as device_id when deviceId omitted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { db, calls } = makeFakeDb([[sampleRow]]);
|
||||
const repo = new DeviceTokenRepository({ db });
|
||||
await repo.register("1", { deviceToken: "xyz" });
|
||||
expect(calls[0]?.params?.[0]).toBe("xyz");
|
||||
});
|
||||
|
||||
it("transforms RecordId-shaped ids", { timeout: 5000 }, async () => {
|
||||
const row = { ...sampleRow, id: { tb: "device_token", id: "abc" } };
|
||||
const { db } = makeFakeDb([[row]]);
|
||||
const repo = new DeviceTokenRepository({ db });
|
||||
const res = await repo.register("user:1", { deviceToken: "abc" });
|
||||
expect(res.id).toBe("device_token:abc");
|
||||
});
|
||||
|
||||
it("transforms RecordId-only id", { timeout: 5000 }, async () => {
|
||||
const row = { ...sampleRow, id: { id: "abc" } };
|
||||
const { db } = makeFakeDb([[row]]);
|
||||
const repo = new DeviceTokenRepository({ db });
|
||||
const res = await repo.register("user:1", { deviceToken: "abc" });
|
||||
expect(res.id).toBe("abc");
|
||||
});
|
||||
|
||||
it("leaves id intact when RecordId-shaped but empty", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const row = { ...sampleRow, id: {} as Record<string, unknown> };
|
||||
const { db } = makeFakeDb([[row]]);
|
||||
const repo = new DeviceTokenRepository({ db });
|
||||
const res = await repo.register("user:1", { deviceToken: "abc" });
|
||||
expect(res).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DeviceTokenRepository.findById / findByToken", () => {
|
||||
it("findById returns null when empty", { timeout: 5000 }, async () => {
|
||||
const { db } = makeFakeDb([[]]);
|
||||
const repo = new DeviceTokenRepository({ db });
|
||||
expect(await repo.findById("device_token:1")).toBeNull();
|
||||
});
|
||||
it("findByToken returns null when empty", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { db } = makeFakeDb([[]]);
|
||||
const repo = new DeviceTokenRepository({ db });
|
||||
expect(await repo.findByToken("abc")).toBeNull();
|
||||
});
|
||||
it("findByToken returns the row", { timeout: 5000 }, async () => {
|
||||
const { db } = makeFakeDb([[sampleRow]]);
|
||||
const repo = new DeviceTokenRepository({ db });
|
||||
expect((await repo.findByToken("deadbeef"))?.id).toBe("device_token:1");
|
||||
});
|
||||
it("findById returns the row", { timeout: 5000 }, async () => {
|
||||
const { db } = makeFakeDb([[sampleRow]]);
|
||||
const repo = new DeviceTokenRepository({ db });
|
||||
expect((await repo.findById("device_token:1"))?.id).toBe(
|
||||
"device_token:1"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DeviceTokenRepository.findActiveForUser", () => {
|
||||
it("returns transformed rows", { timeout: 5000 }, async () => {
|
||||
const { db, calls } = makeFakeDb([[sampleRow]]);
|
||||
const repo = new DeviceTokenRepository({ db });
|
||||
const list = await repo.findActiveForUser("user:1");
|
||||
expect(list.length).toBe(1);
|
||||
expect(calls[0]?.sql).toContain("user_id = user:1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DeviceTokenRepository.deactivate", () => {
|
||||
it("returns true on success", { timeout: 5000 }, async () => {
|
||||
const { db } = makeFakeDb([[]]);
|
||||
const repo = new DeviceTokenRepository({ db });
|
||||
expect(await repo.deactivate("device_token:1")).toBe(true);
|
||||
});
|
||||
it("returns false on failure", { timeout: 5000 }, async () => {
|
||||
const repo = new DeviceTokenRepository({
|
||||
db: throwingDb(new Error("boom")),
|
||||
});
|
||||
expect(await repo.deactivate("device_token:1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DeviceTokenRepository.deactivateByToken", () => {
|
||||
it("returns true when row updated", { timeout: 5000 }, async () => {
|
||||
const { db } = makeFakeDb([[sampleRow]]);
|
||||
const repo = new DeviceTokenRepository({ db });
|
||||
expect(await repo.deactivateByToken("deadbeef")).toBe(true);
|
||||
});
|
||||
it("returns false when no rows updated", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { db } = makeFakeDb([[]]);
|
||||
const repo = new DeviceTokenRepository({ db });
|
||||
expect(await repo.deactivateByToken("deadbeef")).toBe(false);
|
||||
});
|
||||
it("returns false on adapter failure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const repo = new DeviceTokenRepository({
|
||||
db: throwingDb(new Error("boom")),
|
||||
});
|
||||
expect(await repo.deactivateByToken("deadbeef")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DeviceTokenRepository.touchLastUsed", () => {
|
||||
it("issues an UPDATE", { timeout: 5000 }, async () => {
|
||||
const { db, calls } = makeFakeDb([[]]);
|
||||
const repo = new DeviceTokenRepository({ db });
|
||||
await repo.touchLastUsed("abc");
|
||||
expect(calls[0]?.sql).toContain("UPDATE device_token");
|
||||
expect(calls[0]?.sql).toContain("last_used = time::now()");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DeviceTokenRepository.cleanup", () => {
|
||||
it("rejects negative daysOld", { timeout: 5000 }, async () => {
|
||||
const { db } = makeFakeDb([[]]);
|
||||
const repo = new DeviceTokenRepository({ db });
|
||||
await expect(repo.cleanup(-1)).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.INVALID_CONFIG,
|
||||
});
|
||||
});
|
||||
it("returns deleted count", { timeout: 5000 }, async () => {
|
||||
const { db } = makeFakeDb([[{ id: "device_token:1" }]]);
|
||||
const repo = new DeviceTokenRepository({ db });
|
||||
expect(await repo.cleanup(30)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import {
|
||||
NOTIFICATIONS_PLUGIN_CODES,
|
||||
NotificationsPluginError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import type {
|
||||
DeviceToken,
|
||||
RegisterDeviceToken,
|
||||
} from "../../schemas/push-schemas.js";
|
||||
import type {
|
||||
DeviceTokenRepositoryConfig,
|
||||
IDeviceTokenRepository,
|
||||
} from "./device-token-repository.types.js";
|
||||
|
||||
/**
|
||||
* SurrealDB-backed implementation of {@link IDeviceTokenRepository}.
|
||||
*
|
||||
* Uses positional placeholders (`$p0`, `$p1`, …) to match the genesis
|
||||
* adapter surface.
|
||||
*/
|
||||
export class DeviceTokenRepository implements IDeviceTokenRepository {
|
||||
private readonly db: SurrealAdapter;
|
||||
private readonly tableName = "device_token";
|
||||
|
||||
constructor(config: DeviceTokenRepositoryConfig) {
|
||||
this.db = config.db;
|
||||
}
|
||||
|
||||
private normalizeId(id: string, prefix: string): string {
|
||||
return id.includes(":") ? id : `${prefix}:${id}`;
|
||||
}
|
||||
|
||||
private transformRecord(record: unknown): DeviceToken {
|
||||
if (record === null || typeof record !== "object") {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.DEVICE_TOKEN_NOT_FOUND,
|
||||
"Device-token row arrived in a non-object shape"
|
||||
);
|
||||
}
|
||||
const rec = record as Record<string, unknown>;
|
||||
let id: unknown = rec["id"];
|
||||
if (id !== null && typeof id === "object") {
|
||||
const idObj = id as { tb?: string; id?: string };
|
||||
if (idObj.tb !== undefined && idObj.id !== undefined) {
|
||||
id = `${String(idObj.tb)}:${String(idObj.id)}`;
|
||||
} else if (idObj.id !== undefined) {
|
||||
id = String(idObj.id);
|
||||
}
|
||||
}
|
||||
return { ...rec, id: String(id) } as DeviceToken;
|
||||
}
|
||||
|
||||
async register(
|
||||
userId: string,
|
||||
data: RegisterDeviceToken
|
||||
): Promise<DeviceToken> {
|
||||
const params: unknown[] = [];
|
||||
const sets: string[] = [];
|
||||
sets.push(`user_id = ${this.normalizeId(userId, "user")}`);
|
||||
params.push(data.deviceId ?? data.deviceToken);
|
||||
sets.push(`device_id = $p${params.length - 1}`);
|
||||
params.push(data.deviceToken);
|
||||
sets.push(`token = $p${params.length - 1}`);
|
||||
params.push(data.deviceType ?? "ios");
|
||||
sets.push(`platform = $p${params.length - 1}`);
|
||||
params.push(data.environment ?? "sandbox");
|
||||
sets.push(`environment = $p${params.length - 1}`);
|
||||
if (data.appVersion !== undefined) {
|
||||
params.push(data.appVersion);
|
||||
sets.push(`app_version = $p${params.length - 1}`);
|
||||
}
|
||||
if (data.osVersion !== undefined) {
|
||||
params.push(data.osVersion);
|
||||
sets.push(`os_version = $p${params.length - 1}`);
|
||||
}
|
||||
sets.push("is_active = true");
|
||||
sets.push("last_used = time::now()");
|
||||
sets.push("created_at = time::now()");
|
||||
sets.push("updated_at = time::now()");
|
||||
|
||||
const sql = `CREATE ${this.tableName} SET ${sets.join(", ")}`;
|
||||
let rows: DeviceToken[];
|
||||
try {
|
||||
rows = await this.db.query<DeviceToken>(sql, params);
|
||||
} catch (cause) {
|
||||
// SurrealDB rejects duplicate unique-token inserts.
|
||||
const message =
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
if (message.toLowerCase().includes("token")) {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.DEVICE_TOKEN_DUPLICATE,
|
||||
"Device token is already registered",
|
||||
{
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
context: { token: data.deviceToken },
|
||||
}
|
||||
);
|
||||
}
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.DEVICE_TOKEN_CREATE_FAILED,
|
||||
"Failed to register device token",
|
||||
{
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
context: { token: data.deviceToken },
|
||||
}
|
||||
);
|
||||
}
|
||||
const created = rows[0];
|
||||
if (created === undefined || created === null) {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.DEVICE_TOKEN_CREATE_FAILED,
|
||||
"Adapter returned empty row after device-token CREATE"
|
||||
);
|
||||
}
|
||||
return this.transformRecord(created);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<DeviceToken | null> {
|
||||
const tid = this.normalizeId(id, this.tableName);
|
||||
const rows = await this.db.query<DeviceToken>(`SELECT * FROM ${tid}`);
|
||||
const row = rows[0];
|
||||
return row === undefined || row === null
|
||||
? null
|
||||
: this.transformRecord(row);
|
||||
}
|
||||
|
||||
async findByToken(token: string): Promise<DeviceToken | null> {
|
||||
const rows = await this.db.query<DeviceToken>(
|
||||
`SELECT * FROM ${this.tableName} WHERE token = $p0 AND is_active = true LIMIT 1`,
|
||||
[token]
|
||||
);
|
||||
const row = rows[0];
|
||||
return row === undefined || row === null
|
||||
? null
|
||||
: this.transformRecord(row);
|
||||
}
|
||||
|
||||
async findActiveForUser(userId: string): Promise<DeviceToken[]> {
|
||||
const userRid = this.normalizeId(userId, "user");
|
||||
const sql = `SELECT * FROM ${this.tableName} WHERE user_id = ${userRid} AND is_active = true ORDER BY last_used DESC`;
|
||||
const rows = await this.db.query<DeviceToken>(sql);
|
||||
return rows.map((r) => this.transformRecord(r));
|
||||
}
|
||||
|
||||
async deactivate(id: string): Promise<boolean> {
|
||||
const tid = this.normalizeId(id, this.tableName);
|
||||
try {
|
||||
await this.db.query(
|
||||
`UPDATE ${tid} SET is_active = false, updated_at = time::now()`
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async deactivateByToken(token: string): Promise<boolean> {
|
||||
try {
|
||||
const rows = await this.db.query<DeviceToken>(
|
||||
`UPDATE ${this.tableName} SET is_active = false, updated_at = time::now() WHERE token = $p0`,
|
||||
[token]
|
||||
);
|
||||
return rows.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async touchLastUsed(token: string): Promise<void> {
|
||||
await this.db.query(
|
||||
`UPDATE ${this.tableName} SET last_used = time::now(), updated_at = time::now() WHERE token = $p0`,
|
||||
[token]
|
||||
);
|
||||
}
|
||||
|
||||
async cleanup(daysOld: number): Promise<number> {
|
||||
if (daysOld < 0) {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.INVALID_CONFIG,
|
||||
"cleanup requires daysOld >= 0",
|
||||
{ context: { daysOld } }
|
||||
);
|
||||
}
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - daysOld);
|
||||
const sql = `DELETE ${this.tableName} WHERE (is_active = false AND updated_at < '${cutoff.toISOString()}') OR (last_used < '${cutoff.toISOString()}') RETURN BEFORE`;
|
||||
const rows = await this.db.query<DeviceToken>(sql);
|
||||
return rows.length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import type {
|
||||
DeviceToken,
|
||||
RegisterDeviceToken,
|
||||
} from "../../schemas/push-schemas.js";
|
||||
|
||||
/** Constructor options for {@link IDeviceTokenRepository}. */
|
||||
export interface DeviceTokenRepositoryConfig {
|
||||
db: SurrealAdapter;
|
||||
}
|
||||
|
||||
/** Public contract for device-token persistence. */
|
||||
export interface IDeviceTokenRepository {
|
||||
register(userId: string, data: RegisterDeviceToken): Promise<DeviceToken>;
|
||||
findById(id: string): Promise<DeviceToken | null>;
|
||||
findByToken(token: string): Promise<DeviceToken | null>;
|
||||
findActiveForUser(userId: string): Promise<DeviceToken[]>;
|
||||
deactivate(id: string): Promise<boolean>;
|
||||
deactivateByToken(token: string): Promise<boolean>;
|
||||
touchLastUsed(token: string): Promise<void>;
|
||||
cleanup(daysOld: number): Promise<number>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { DeviceTokenRepository } from "./device-token-repository.js";
|
||||
export type {
|
||||
DeviceTokenRepositoryConfig,
|
||||
IDeviceTokenRepository,
|
||||
} from "./device-token-repository.types.js";
|
||||
@@ -0,0 +1,8 @@
|
||||
export { NotificationRepository } from "./notification-repository.js";
|
||||
export type {
|
||||
BulkOperationResult,
|
||||
FindNotificationsOptions,
|
||||
GroupedNotifications,
|
||||
INotificationRepository,
|
||||
NotificationRepositoryConfig,
|
||||
} from "./notification-repository.types.js";
|
||||
@@ -0,0 +1,81 @@
|
||||
# `repositories/notification-repository`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
SurrealDB-backed repository for the `notification` table. Owns every
|
||||
SurrealQL statement the plugin issues against notifications and pins
|
||||
the placeholder convention to positional `$p0`, `$p1`, … so the
|
||||
`DatabaseAdapter.query(sql, params: readonly unknown[])`
|
||||
surface can rewrite positional arrays into `{p0, p1, …}` records before
|
||||
handing them to the surrealdb.js driver.
|
||||
|
||||
## Features
|
||||
|
||||
- Twelve operations: `create`, `findById`, `findByRecipient`,
|
||||
`getUnreadCount`, `update`, `markAsRead`, `markManyAsRead`,
|
||||
`markAllAsRead`, `dismiss`, `delete`, `findGroupedByType`,
|
||||
`deleteOldRead`.
|
||||
- Record-id normalisation — both bare ids (`abc`) and prefixed ids
|
||||
(`notification:abc`) are accepted at the boundary; the repository
|
||||
prefixes / strips as needed.
|
||||
- SurrealDB `RecordId` row shapes (`{ tb, id }`) collapse to
|
||||
string ids inside `transformRecord(...)` so route responses always
|
||||
serialise as `"notification:abc"`.
|
||||
- Retention cleanup — `deleteOldRead(daysOld)` deletes read
|
||||
notifications older than `daysOld` and returns the row count using
|
||||
`RETURN BEFORE`.
|
||||
- Stable error codes: `NOTIFICATION_CREATE_FAILED`,
|
||||
`NOTIFICATION_NOT_FOUND`, `INVALID_CONFIG` (for negative
|
||||
`daysOld`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { NotificationRepository } from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
const repo = new NotificationRepository({ db });
|
||||
const n = await repo.create({
|
||||
type: "friend_request",
|
||||
recipient: "user:42",
|
||||
content: "sent you a friend request",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Query with filters and grouping:
|
||||
|
||||
```ts
|
||||
const unread = await repo.findByRecipient("user:42", "site:main", {
|
||||
unreadOnly: true,
|
||||
type: "post_comment",
|
||||
since: "2026-05-01T00:00:00Z",
|
||||
limit: 50,
|
||||
});
|
||||
const grouped = await repo.findGroupedByType("user:42", "site:main");
|
||||
const cleaned = await repo.deleteOldRead(30);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NotificationRepository(config: { db: SurrealAdapter })`
|
||||
- `INotificationRepository` — interface (every method documented).
|
||||
- `FindNotificationsOptions` — `{ limit?, offset?, unreadOnly?, type?, since? }`.
|
||||
- `BulkOperationResult` — `{ count, updated }`.
|
||||
- `GroupedNotifications` — `Record<string, Notification[]>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`markManyAsRead` interpolates the formatted id list directly into the
|
||||
SurrealQL string rather than parameterising because SurrealDB does
|
||||
not accept positional record-id arrays inside `IN [...]` clauses. The
|
||||
ids are normalised through `normalizeId` so injection-style values
|
||||
never reach the formatted output. `deleteOldRead` interpolates the
|
||||
ISO date for the same reason — `<datetime>` literals are accepted but
|
||||
parameterised strings are rejected by SurrealDB's date-coercion. The
|
||||
caller is the only producer of `daysOld`, which is gated to
|
||||
non-negative integers at the boundary.
|
||||
@@ -0,0 +1,459 @@
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
NOTIFICATIONS_PLUGIN_CODES,
|
||||
NotificationsPluginError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { NotificationRepository } from "./notification-repository.js";
|
||||
|
||||
interface QueryCall {
|
||||
sql: string;
|
||||
params: readonly unknown[] | undefined;
|
||||
}
|
||||
|
||||
function makeFakeDb(rows: unknown[][]): {
|
||||
db: SurrealAdapter;
|
||||
calls: QueryCall[];
|
||||
} {
|
||||
const calls: QueryCall[] = [];
|
||||
let i = 0;
|
||||
const db = {
|
||||
async query<T>(sql: string, params?: readonly unknown[]) {
|
||||
calls.push({ sql, params });
|
||||
const row = rows[i++] ?? [];
|
||||
return row as T[];
|
||||
},
|
||||
} as unknown as SurrealAdapter;
|
||||
return { db, calls };
|
||||
}
|
||||
|
||||
function failingDb(): SurrealAdapter {
|
||||
return {
|
||||
async query() {
|
||||
throw new Error("boom");
|
||||
},
|
||||
} as unknown as SurrealAdapter;
|
||||
}
|
||||
|
||||
describe("NotificationRepository.create", () => {
|
||||
it("creates a notification with positional params", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const created = {
|
||||
id: "notification:1",
|
||||
type: "friend_request",
|
||||
recipient: "user:1",
|
||||
content: "hi",
|
||||
read: false,
|
||||
dismissed: false,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
const { db, calls } = makeFakeDb([[created]]);
|
||||
const repo = new NotificationRepository({ db });
|
||||
const res = await repo.create({
|
||||
type: "friend_request",
|
||||
recipient: "user:1",
|
||||
actor: "user:2",
|
||||
site: "site:main",
|
||||
content: "hi",
|
||||
target_id: "post:5",
|
||||
target_type: "post",
|
||||
metadata: { title: "T" },
|
||||
});
|
||||
expect(res.id).toBe("notification:1");
|
||||
expect(calls[0]?.sql).toContain("CREATE notification SET");
|
||||
expect(calls[0]?.sql).toContain("recipient = user:1");
|
||||
expect(calls[0]?.sql).toContain("actor = user:2");
|
||||
expect(calls[0]?.sql).toContain("site = site:main");
|
||||
});
|
||||
|
||||
it("throws NOTIFICATION_CREATE_FAILED on empty row", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { db } = makeFakeDb([[]]);
|
||||
const repo = new NotificationRepository({ db });
|
||||
await expect(
|
||||
repo.create({
|
||||
type: "post_like",
|
||||
recipient: "user:1",
|
||||
content: "x",
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.NOTIFICATION_CREATE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("transforms RecordId-shaped rows", { timeout: 5000 }, async () => {
|
||||
const { db } = makeFakeDb([
|
||||
[
|
||||
{
|
||||
id: { tb: "notification", id: "abc" },
|
||||
type: "system",
|
||||
recipient: "user:1",
|
||||
content: "x",
|
||||
read: false,
|
||||
dismissed: false,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
]);
|
||||
const repo = new NotificationRepository({ db });
|
||||
const res = await repo.create({
|
||||
type: "system",
|
||||
recipient: "user:1",
|
||||
content: "x",
|
||||
});
|
||||
expect(res.id).toBe("notification:abc");
|
||||
});
|
||||
|
||||
it("normalises bare recipient id", { timeout: 5000 }, async () => {
|
||||
const { db, calls } = makeFakeDb([
|
||||
[
|
||||
{
|
||||
id: "notification:1",
|
||||
type: "system",
|
||||
recipient: "user:42",
|
||||
content: "x",
|
||||
read: false,
|
||||
dismissed: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
],
|
||||
]);
|
||||
const repo = new NotificationRepository({ db });
|
||||
await repo.create({
|
||||
type: "system",
|
||||
recipient: "42",
|
||||
content: "x",
|
||||
});
|
||||
expect(calls[0]?.sql).toContain("recipient = user:42");
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationRepository.findById", () => {
|
||||
it("returns null when no row", { timeout: 5000 }, async () => {
|
||||
const { db } = makeFakeDb([[]]);
|
||||
const repo = new NotificationRepository({ db });
|
||||
expect(await repo.findById("notification:1")).toBeNull();
|
||||
});
|
||||
it("transforms RecordId-only id (no tb)", { timeout: 5000 }, async () => {
|
||||
const { db } = makeFakeDb([
|
||||
[
|
||||
{
|
||||
id: { id: "abc" },
|
||||
type: "system",
|
||||
recipient: "user:1",
|
||||
content: "x",
|
||||
read: false,
|
||||
dismissed: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
],
|
||||
]);
|
||||
const repo = new NotificationRepository({ db });
|
||||
const res = await repo.findById("abc");
|
||||
expect(res?.id).toBe("abc");
|
||||
});
|
||||
it("throws when row is non-object", { timeout: 5000 }, async () => {
|
||||
const { db } = makeFakeDb([[42 as unknown]]);
|
||||
const repo = new NotificationRepository({ db });
|
||||
await expect(repo.findById("notification:1")).rejects.toBeInstanceOf(
|
||||
NotificationsPluginError
|
||||
);
|
||||
});
|
||||
it("leaves id when RecordId-shaped but empty", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { db } = makeFakeDb([
|
||||
[
|
||||
{
|
||||
id: {} as Record<string, unknown>,
|
||||
type: "system",
|
||||
recipient: "user:1",
|
||||
content: "x",
|
||||
read: false,
|
||||
dismissed: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
],
|
||||
]);
|
||||
const repo = new NotificationRepository({ db });
|
||||
const res = await repo.findById("notification:1");
|
||||
expect(res).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationRepository.findByRecipient", () => {
|
||||
it("builds a WHERE with site filter and options", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { db, calls } = makeFakeDb([[]]);
|
||||
const repo = new NotificationRepository({ db });
|
||||
await repo.findByRecipient("user:1", "site:main", {
|
||||
unreadOnly: true,
|
||||
type: "post_like",
|
||||
since: "2026-01-01T00:00:00Z",
|
||||
limit: 10,
|
||||
offset: 5,
|
||||
});
|
||||
const sql = calls[0]?.sql ?? "";
|
||||
expect(sql).toContain("WHERE recipient = user:1");
|
||||
expect(sql).toContain("AND site = site:main");
|
||||
expect(sql).toContain("AND read = false");
|
||||
expect(sql).toContain("AND type = $p");
|
||||
expect(sql).toContain("AND created_at > $p");
|
||||
expect(sql).toContain("LIMIT $p");
|
||||
expect(sql).toContain("START $p");
|
||||
expect(calls[0]?.params).toEqual([
|
||||
"post_like",
|
||||
"2026-01-01T00:00:00Z",
|
||||
10,
|
||||
5,
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits site filter when undefined", { timeout: 5000 }, async () => {
|
||||
const { db, calls } = makeFakeDb([[]]);
|
||||
const repo = new NotificationRepository({ db });
|
||||
await repo.findByRecipient("user:1", undefined);
|
||||
expect(calls[0]?.sql).not.toContain("site");
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationRepository.getUnreadCount", () => {
|
||||
it("returns 0 when adapter returns empty", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { db } = makeFakeDb([[]]);
|
||||
const repo = new NotificationRepository({ db });
|
||||
expect(await repo.getUnreadCount("user:1", "site:main")).toBe(0);
|
||||
});
|
||||
it("returns the row count", { timeout: 5000 }, async () => {
|
||||
const { db } = makeFakeDb([[{ count: 7 }]]);
|
||||
const repo = new NotificationRepository({ db });
|
||||
expect(await repo.getUnreadCount("user:1", undefined)).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationRepository.update", () => {
|
||||
it("falls through to findById on empty body", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { db, calls } = makeFakeDb([
|
||||
[
|
||||
{
|
||||
id: "notification:1",
|
||||
type: "system",
|
||||
recipient: "user:1",
|
||||
content: "x",
|
||||
read: false,
|
||||
dismissed: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
],
|
||||
]);
|
||||
const repo = new NotificationRepository({ db });
|
||||
const res = await repo.update("notification:1", {});
|
||||
expect(res?.id).toBe("notification:1");
|
||||
expect(calls[0]?.sql).toContain("SELECT * FROM notification:1");
|
||||
});
|
||||
|
||||
it("sets read / dismissed / metadata", { timeout: 5000 }, async () => {
|
||||
const { db, calls } = makeFakeDb([
|
||||
[
|
||||
{
|
||||
id: "notification:1",
|
||||
type: "system",
|
||||
recipient: "user:1",
|
||||
content: "x",
|
||||
read: true,
|
||||
dismissed: true,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
],
|
||||
]);
|
||||
const repo = new NotificationRepository({ db });
|
||||
await repo.update("notification:1", {
|
||||
read: true,
|
||||
dismissed: true,
|
||||
metadata: { title: "T" },
|
||||
});
|
||||
const sql = calls[0]?.sql ?? "";
|
||||
expect(sql).toContain("read = $p0");
|
||||
expect(sql).toContain("dismissed = $p1");
|
||||
expect(sql).toContain("metadata = $p2");
|
||||
expect(sql).toContain("updated_at = time::now()");
|
||||
});
|
||||
|
||||
it("returns null when adapter returns empty", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { db } = makeFakeDb([[]]);
|
||||
const repo = new NotificationRepository({ db });
|
||||
expect(await repo.update("notification:1", { read: true })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationRepository.markAsRead / dismiss", () => {
|
||||
it("markAsRead delegates to update", { timeout: 5000 }, async () => {
|
||||
const { db } = makeFakeDb([
|
||||
[
|
||||
{
|
||||
id: "notification:1",
|
||||
type: "system",
|
||||
recipient: "user:1",
|
||||
content: "x",
|
||||
read: true,
|
||||
dismissed: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
],
|
||||
]);
|
||||
const repo = new NotificationRepository({ db });
|
||||
const n = await repo.markAsRead("notification:1");
|
||||
expect(n?.read).toBe(true);
|
||||
});
|
||||
it("dismiss delegates to update", { timeout: 5000 }, async () => {
|
||||
const { db } = makeFakeDb([
|
||||
[
|
||||
{
|
||||
id: "notification:1",
|
||||
type: "system",
|
||||
recipient: "user:1",
|
||||
content: "x",
|
||||
read: false,
|
||||
dismissed: true,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
],
|
||||
]);
|
||||
const repo = new NotificationRepository({ db });
|
||||
const n = await repo.dismiss("notification:1");
|
||||
expect(n?.dismissed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationRepository.markManyAsRead", () => {
|
||||
it("returns zero for empty ids", { timeout: 5000 }, async () => {
|
||||
const { db } = makeFakeDb([[]]);
|
||||
const repo = new NotificationRepository({ db });
|
||||
const res = await repo.markManyAsRead([]);
|
||||
expect(res).toEqual({ count: 0, updated: 0 });
|
||||
});
|
||||
it("formats prefix-less ids", { timeout: 5000 }, async () => {
|
||||
const { db, calls } = makeFakeDb([
|
||||
[{ id: "notification:1" }, { id: "notification:2" }],
|
||||
]);
|
||||
const repo = new NotificationRepository({ db });
|
||||
const res = await repo.markManyAsRead(["1", "notification:2"]);
|
||||
expect(res.updated).toBe(2);
|
||||
expect(calls[0]?.sql).toContain("'notification:1'");
|
||||
expect(calls[0]?.sql).toContain("'notification:2'");
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationRepository.markAllAsRead", () => {
|
||||
it("filters by recipient and site", { timeout: 5000 }, async () => {
|
||||
const { db, calls } = makeFakeDb([
|
||||
[{ id: "notification:1" }, { id: "notification:2" }],
|
||||
]);
|
||||
const repo = new NotificationRepository({ db });
|
||||
const res = await repo.markAllAsRead("user:1", "site:main");
|
||||
expect(res.updated).toBe(2);
|
||||
expect(calls[0]?.sql).toContain("WHERE recipient = user:1");
|
||||
expect(calls[0]?.sql).toContain("AND site = site:main");
|
||||
});
|
||||
it("works without a site filter", { timeout: 5000 }, async () => {
|
||||
const { db, calls } = makeFakeDb([[]]);
|
||||
const repo = new NotificationRepository({ db });
|
||||
await repo.markAllAsRead("user:1", undefined);
|
||||
expect(calls[0]?.sql).not.toContain("site");
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationRepository.delete", () => {
|
||||
it("returns true on success", { timeout: 5000 }, async () => {
|
||||
const { db } = makeFakeDb([[]]);
|
||||
const repo = new NotificationRepository({ db });
|
||||
expect(await repo.delete("notification:1")).toBe(true);
|
||||
});
|
||||
it("returns false on adapter failure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const repo = new NotificationRepository({ db: failingDb() });
|
||||
expect(await repo.delete("notification:1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationRepository.findGroupedByType", () => {
|
||||
it("groups notifications by type", { timeout: 5000 }, async () => {
|
||||
const rows = [
|
||||
{
|
||||
id: "notification:1",
|
||||
type: "friend_request",
|
||||
recipient: "user:1",
|
||||
content: "x",
|
||||
read: false,
|
||||
dismissed: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
{
|
||||
id: "notification:2",
|
||||
type: "friend_request",
|
||||
recipient: "user:1",
|
||||
content: "y",
|
||||
read: false,
|
||||
dismissed: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
{
|
||||
id: "notification:3",
|
||||
type: "post_like",
|
||||
recipient: "user:1",
|
||||
content: "z",
|
||||
read: false,
|
||||
dismissed: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
];
|
||||
const { db } = makeFakeDb([rows]);
|
||||
const repo = new NotificationRepository({ db });
|
||||
const grouped = await repo.findGroupedByType("user:1", undefined);
|
||||
expect(grouped.friend_request?.length).toBe(2);
|
||||
expect(grouped.post_like?.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationRepository.deleteOldRead", () => {
|
||||
it("rejects negative daysOld", { timeout: 5000 }, async () => {
|
||||
const { db } = makeFakeDb([[]]);
|
||||
const repo = new NotificationRepository({ db });
|
||||
await expect(repo.deleteOldRead(-1)).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.INVALID_CONFIG,
|
||||
});
|
||||
});
|
||||
it("returns deleted count from RETURN BEFORE", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { db, calls } = makeFakeDb([
|
||||
[{ id: "notification:1" }, { id: "notification:2" }],
|
||||
]);
|
||||
const repo = new NotificationRepository({ db });
|
||||
const n = await repo.deleteOldRead(7);
|
||||
expect(n).toBe(2);
|
||||
expect(calls[0]?.sql).toContain("DELETE notification");
|
||||
expect(calls[0]?.sql).toContain("AND read = true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import {
|
||||
NOTIFICATIONS_PLUGIN_CODES,
|
||||
NotificationsPluginError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import type {
|
||||
CreateNotification,
|
||||
Notification,
|
||||
UpdateNotification,
|
||||
} from "../../schemas/notification-schemas.js";
|
||||
import type {
|
||||
BulkOperationResult,
|
||||
FindNotificationsOptions,
|
||||
GroupedNotifications,
|
||||
INotificationRepository,
|
||||
NotificationRepositoryConfig,
|
||||
} from "./notification-repository.types.js";
|
||||
|
||||
/**
|
||||
* SurrealDB-backed implementation of {@link INotificationRepository}.
|
||||
*
|
||||
* Every SurrealQL statement uses positional placeholders (`$p0`,
|
||||
* `$p1`, …) to match the genesis `DatabaseAdapter.query(sql,
|
||||
* params: readonly unknown[])` surface. The adapter rewrites
|
||||
* positional arrays into a `{p0, p1, …}` record before handing them
|
||||
* to the surrealdb.js driver.
|
||||
*/
|
||||
export class NotificationRepository implements INotificationRepository {
|
||||
private readonly db: SurrealAdapter;
|
||||
private readonly tableName = "notification";
|
||||
|
||||
constructor(config: NotificationRepositoryConfig) {
|
||||
this.db = config.db;
|
||||
}
|
||||
|
||||
private normalizeId(id: string, prefix: string): string {
|
||||
return id.includes(":") ? id : `${prefix}:${id}`;
|
||||
}
|
||||
|
||||
private transformRecord(record: unknown): Notification {
|
||||
if (record === null || typeof record !== "object") {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.NOTIFICATION_NOT_FOUND,
|
||||
"Repository received a non-object row from the adapter"
|
||||
);
|
||||
}
|
||||
const rec = record as Record<string, unknown>;
|
||||
let id: unknown = rec["id"];
|
||||
if (id !== null && typeof id === "object") {
|
||||
const idObj = id as { tb?: string; id?: string };
|
||||
if (idObj.tb !== undefined && idObj.id !== undefined) {
|
||||
id = `${String(idObj.tb)}:${String(idObj.id)}`;
|
||||
} else if (idObj.id !== undefined) {
|
||||
id = String(idObj.id);
|
||||
}
|
||||
}
|
||||
return { ...rec, id: String(id) } as Notification;
|
||||
}
|
||||
|
||||
async create(data: CreateNotification): Promise<Notification> {
|
||||
const params: unknown[] = [];
|
||||
const sets: string[] = [];
|
||||
const pushSet = (clause: string, value: unknown): void => {
|
||||
params.push(value);
|
||||
sets.push(clause.replace("$p", `$p${params.length - 1}`));
|
||||
};
|
||||
pushSet("type = $p", data.type);
|
||||
sets.push(`recipient = ${this.normalizeId(data.recipient, "user")}`);
|
||||
if (data.actor !== undefined) {
|
||||
sets.push(`actor = ${this.normalizeId(data.actor, "user")}`);
|
||||
}
|
||||
if (data.site !== undefined) {
|
||||
sets.push(`site = ${this.normalizeId(data.site, "site")}`);
|
||||
}
|
||||
if (data.target_id !== undefined) {
|
||||
pushSet("target_id = $p", data.target_id);
|
||||
}
|
||||
if (data.target_type !== undefined) {
|
||||
pushSet("target_type = $p", data.target_type);
|
||||
}
|
||||
pushSet("content = $p", data.content);
|
||||
if (data.metadata !== undefined) {
|
||||
pushSet("metadata = $p", data.metadata);
|
||||
}
|
||||
pushSet("read = $p", data.read ?? false);
|
||||
pushSet("dismissed = $p", data.dismissed ?? false);
|
||||
sets.push("created_at = time::now()");
|
||||
sets.push("updated_at = time::now()");
|
||||
|
||||
const sql = `CREATE ${this.tableName} SET ${sets.join(", ")}`;
|
||||
const rows = await this.db.query<Notification>(sql, params);
|
||||
const created = rows[0];
|
||||
if (created === undefined || created === null) {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.NOTIFICATION_CREATE_FAILED,
|
||||
"Failed to create notification — adapter returned no row"
|
||||
);
|
||||
}
|
||||
return this.transformRecord(created);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Notification | null> {
|
||||
const notificationId = this.normalizeId(id, this.tableName);
|
||||
const rows = await this.db.query<Notification>(
|
||||
`SELECT * FROM ${notificationId}`
|
||||
);
|
||||
const row = rows[0];
|
||||
if (row === undefined || row === null) return null;
|
||||
return this.transformRecord(row);
|
||||
}
|
||||
|
||||
async findByRecipient(
|
||||
recipientId: string,
|
||||
siteId: string | undefined,
|
||||
options: FindNotificationsOptions = {}
|
||||
): Promise<Notification[]> {
|
||||
const params: unknown[] = [];
|
||||
const recipientRid = this.normalizeId(recipientId, "user");
|
||||
const parts: string[] = [
|
||||
`SELECT * FROM ${this.tableName}`,
|
||||
`WHERE recipient = ${recipientRid}`,
|
||||
];
|
||||
if (siteId !== undefined) {
|
||||
const siteRid = this.normalizeId(siteId, "site");
|
||||
parts.push(`AND site = ${siteRid}`);
|
||||
}
|
||||
if (options.unreadOnly === true) {
|
||||
parts.push("AND read = false");
|
||||
}
|
||||
if (options.type !== undefined) {
|
||||
params.push(options.type);
|
||||
parts.push(`AND type = $p${params.length - 1}`);
|
||||
}
|
||||
if (options.since !== undefined) {
|
||||
params.push(options.since);
|
||||
parts.push(`AND created_at > $p${params.length - 1}`);
|
||||
}
|
||||
parts.push("ORDER BY created_at DESC");
|
||||
if (options.limit !== undefined) {
|
||||
params.push(options.limit);
|
||||
parts.push(`LIMIT $p${params.length - 1}`);
|
||||
}
|
||||
if (options.offset !== undefined && options.offset > 0) {
|
||||
params.push(options.offset);
|
||||
parts.push(`START $p${params.length - 1}`);
|
||||
}
|
||||
const rows = await this.db.query<Notification>(parts.join(" "), params);
|
||||
return rows.map((r) => this.transformRecord(r));
|
||||
}
|
||||
|
||||
async getUnreadCount(
|
||||
recipientId: string,
|
||||
siteId: string | undefined
|
||||
): Promise<number> {
|
||||
const recipientRid = this.normalizeId(recipientId, "user");
|
||||
const parts: string[] = [
|
||||
`SELECT count() AS count FROM ${this.tableName}`,
|
||||
`WHERE recipient = ${recipientRid}`,
|
||||
];
|
||||
if (siteId !== undefined) {
|
||||
const siteRid = this.normalizeId(siteId, "site");
|
||||
parts.push(`AND site = ${siteRid}`);
|
||||
}
|
||||
parts.push("AND read = false");
|
||||
parts.push("GROUP ALL");
|
||||
const rows = await this.db.query<{ count: number }>(parts.join(" "));
|
||||
const first = rows[0];
|
||||
return first?.count ?? 0;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: UpdateNotification
|
||||
): Promise<Notification | null> {
|
||||
const notificationId = this.normalizeId(id, this.tableName);
|
||||
const params: unknown[] = [];
|
||||
const sets: string[] = [];
|
||||
const pushSet = (clause: string, value: unknown): void => {
|
||||
params.push(value);
|
||||
sets.push(clause.replace("$p", `$p${params.length - 1}`));
|
||||
};
|
||||
if (data.read !== undefined) pushSet("read = $p", data.read);
|
||||
if (data.dismissed !== undefined)
|
||||
pushSet("dismissed = $p", data.dismissed);
|
||||
if (data.metadata !== undefined)
|
||||
pushSet("metadata = $p", data.metadata);
|
||||
if (sets.length === 0) {
|
||||
return this.findById(id);
|
||||
}
|
||||
sets.push("updated_at = time::now()");
|
||||
const sql = `UPDATE ${notificationId} SET ${sets.join(", ")}`;
|
||||
const rows = await this.db.query<Notification>(sql, params);
|
||||
const updated = rows[0];
|
||||
if (updated === undefined || updated === null) {
|
||||
return null;
|
||||
}
|
||||
return this.transformRecord(updated);
|
||||
}
|
||||
|
||||
async markAsRead(id: string): Promise<Notification | null> {
|
||||
return this.update(id, { read: true });
|
||||
}
|
||||
|
||||
async markManyAsRead(
|
||||
ids: ReadonlyArray<string>
|
||||
): Promise<BulkOperationResult> {
|
||||
if (ids.length === 0) return { count: 0, updated: 0 };
|
||||
const formatted = ids
|
||||
.map((id) => this.normalizeId(id, this.tableName))
|
||||
.map((id) => `'${id}'`);
|
||||
const sql = `UPDATE ${this.tableName} SET read = true, updated_at = time::now() WHERE id IN [${formatted.join(", ")}]`;
|
||||
const rows = await this.db.query<Notification>(sql);
|
||||
return { count: rows.length, updated: rows.length };
|
||||
}
|
||||
|
||||
async markAllAsRead(
|
||||
recipientId: string,
|
||||
siteId: string | undefined
|
||||
): Promise<BulkOperationResult> {
|
||||
const recipientRid = this.normalizeId(recipientId, "user");
|
||||
const parts: string[] = [
|
||||
`UPDATE ${this.tableName} SET read = true, updated_at = time::now()`,
|
||||
`WHERE recipient = ${recipientRid}`,
|
||||
];
|
||||
if (siteId !== undefined) {
|
||||
const siteRid = this.normalizeId(siteId, "site");
|
||||
parts.push(`AND site = ${siteRid}`);
|
||||
}
|
||||
parts.push("AND read = false");
|
||||
const rows = await this.db.query<Notification>(parts.join(" "));
|
||||
return { count: rows.length, updated: rows.length };
|
||||
}
|
||||
|
||||
async dismiss(id: string): Promise<Notification | null> {
|
||||
return this.update(id, { dismissed: true });
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const notificationId = this.normalizeId(id, this.tableName);
|
||||
try {
|
||||
await this.db.query(`DELETE ${notificationId}`);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async findGroupedByType(
|
||||
recipientId: string,
|
||||
siteId: string | undefined
|
||||
): Promise<GroupedNotifications> {
|
||||
const all = await this.findByRecipient(recipientId, siteId);
|
||||
const grouped: GroupedNotifications = {};
|
||||
for (const n of all) {
|
||||
const key = String(n.type);
|
||||
const bucket = grouped[key];
|
||||
if (bucket === undefined) {
|
||||
grouped[key] = [n];
|
||||
} else {
|
||||
bucket.push(n);
|
||||
}
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
async deleteOldRead(daysOld: number): Promise<number> {
|
||||
if (daysOld < 0) {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.INVALID_CONFIG,
|
||||
"deleteOldRead requires daysOld >= 0",
|
||||
{ context: { daysOld } }
|
||||
);
|
||||
}
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - daysOld);
|
||||
const sql = `DELETE ${this.tableName} WHERE created_at < '${cutoff.toISOString()}' AND read = true RETURN BEFORE`;
|
||||
const rows = await this.db.query<Notification>(sql);
|
||||
return rows.length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import type {
|
||||
CreateNotification,
|
||||
Notification,
|
||||
UpdateNotification,
|
||||
} from "../../schemas/notification-schemas.js";
|
||||
|
||||
/** Constructor options for {@link INotificationRepository}. */
|
||||
export interface NotificationRepositoryConfig {
|
||||
db: SurrealAdapter;
|
||||
}
|
||||
|
||||
/** Query options for `findByRecipient`. */
|
||||
export interface FindNotificationsOptions {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
unreadOnly?: boolean;
|
||||
type?: string;
|
||||
since?: string;
|
||||
}
|
||||
|
||||
/** Aggregate result for bulk operations. */
|
||||
export interface BulkOperationResult {
|
||||
count: number;
|
||||
updated: number;
|
||||
}
|
||||
|
||||
/** Grouped notifications by type. */
|
||||
export type GroupedNotifications = Record<string, Notification[]>;
|
||||
|
||||
/** Public contract for notification CRUD over the SurrealDB adapter. */
|
||||
export interface INotificationRepository {
|
||||
create(data: CreateNotification): Promise<Notification>;
|
||||
findById(id: string): Promise<Notification | null>;
|
||||
findByRecipient(
|
||||
recipientId: string,
|
||||
siteId: string | undefined,
|
||||
options?: FindNotificationsOptions
|
||||
): Promise<Notification[]>;
|
||||
getUnreadCount(
|
||||
recipientId: string,
|
||||
siteId: string | undefined
|
||||
): Promise<number>;
|
||||
update(id: string, data: UpdateNotification): Promise<Notification | null>;
|
||||
markAsRead(id: string): Promise<Notification | null>;
|
||||
markManyAsRead(ids: ReadonlyArray<string>): Promise<BulkOperationResult>;
|
||||
markAllAsRead(
|
||||
recipientId: string,
|
||||
siteId: string | undefined
|
||||
): Promise<BulkOperationResult>;
|
||||
dismiss(id: string): Promise<Notification | null>;
|
||||
delete(id: string): Promise<boolean>;
|
||||
findGroupedByType(
|
||||
recipientId: string,
|
||||
siteId: string | undefined
|
||||
): Promise<GroupedNotifications>;
|
||||
deleteOldRead(daysOld: number): Promise<number>;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createNotificationsRoutes,
|
||||
type NotificationsRoutesVariables,
|
||||
} from "./notifications-routes.js";
|
||||
@@ -0,0 +1,78 @@
|
||||
# `routes/notifications-routes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Hono OpenAPI route module exposing the `/notifications/*` surface.
|
||||
Eleven endpoints cover list / unread-count / create / get / patch /
|
||||
mark-read / mark-many / mark-all / dismiss / delete and the count
|
||||
endpoint. The module is constructed by `createNotificationsRoutes(...)`
|
||||
and mounted by the plugin under the configured `basePath`. Domain
|
||||
events emit through the host's shared `eventBus` via an injected
|
||||
`emitEvent` callback so sibling plugins observe every state change.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-route Zod validation via `@hono/zod-openapi`'s `createRoute`,
|
||||
using the boundary schemas from `schemas/notification-schemas.ts`.
|
||||
- Authentication guard — every route reads `c.get("user")` set by the
|
||||
host's auth middleware. Missing user returns `401` with body
|
||||
`{ error, code: NOTIFICATIONS_PLUGIN.UNAUTHENTICATED }`.
|
||||
- Ownership guard — `/get`, `/patch`, `/read`, `/dismiss`, and
|
||||
`/delete` only operate on notifications whose `recipient` matches
|
||||
the authenticated user. Mismatch returns `403`.
|
||||
- OTel emission — `notificationsCreatedTotal{type}`,
|
||||
`notificationsReadTotal`, and `notificationsDeletedTotal` are
|
||||
incremented on successful mutations.
|
||||
- Event-bus forwarding — `notification:created`, `notification:read`,
|
||||
`notification:dismissed`, `notification:deleted` are forwarded via
|
||||
the injected `emitEvent` callback.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createNotificationsRoutes } from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
const routes = createNotificationsRoutes({
|
||||
notificationRepository,
|
||||
emitEvent: (e) => eventBus.emit(e.type, e.payload),
|
||||
});
|
||||
app.route("/notifications", routes);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Compose with host-level middleware (auth, rate-limit, audit):
|
||||
|
||||
```ts
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
|
||||
const host = new OpenAPIHono();
|
||||
host.use("/notifications/*", authMiddleware);
|
||||
host.use("/notifications/*", rateLimit({ max: 100, windowMs: 60_000 }));
|
||||
host.route(
|
||||
"/notifications",
|
||||
createNotificationsRoutes({ notificationRepository, emitEvent })
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createNotificationsRoutes(config: NotificationsRoutesConfig)` →
|
||||
`OpenAPIHono<{ Variables: NotificationsRoutesVariables }>`
|
||||
- `NotificationsRoutesVariables` — `{ user, siteId? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The ownership check uses bidirectional substring matching
|
||||
(`recipient.includes(userId) || userId.includes(recipient)`) so the
|
||||
guard remains permissive across the two record-id forms the upstream
|
||||
producers emit (`abc` and `user:abc`). This matches the upstream
|
||||
behaviour, which has the same heuristic. The route layer never
|
||||
throws — every failure mode resolves to a structured `errorBody(...)`
|
||||
JSON response with the plugin's stable error-code string so HTTP
|
||||
clients can branch on `code` rather than parsing the `error`
|
||||
message.
|
||||
@@ -0,0 +1,491 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { resetNotificationsInstruments } from "../../otel/instruments.js";
|
||||
import type {
|
||||
BulkOperationResult,
|
||||
GroupedNotifications,
|
||||
INotificationRepository,
|
||||
} from "../../repositories/notification-repository/notification-repository.types.js";
|
||||
import type {
|
||||
CreateNotification,
|
||||
Notification,
|
||||
UpdateNotification,
|
||||
} from "../../schemas/notification-schemas.js";
|
||||
import type { NotificationsRouteEvent } from "../../types/types.js";
|
||||
import { createNotificationsRoutes } from "./notifications-routes.js";
|
||||
|
||||
afterEach(() => {
|
||||
resetNotificationsInstruments();
|
||||
});
|
||||
|
||||
const baseNotification: Notification = {
|
||||
id: "notification:1",
|
||||
type: "friend_request",
|
||||
recipient: "user:1",
|
||||
content: "hi",
|
||||
read: false,
|
||||
dismissed: false,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
interface RepoOverrides {
|
||||
create?: (d: CreateNotification) => Promise<Notification>;
|
||||
findById?: (id: string) => Promise<Notification | null>;
|
||||
findByRecipient?: () => Promise<Notification[]>;
|
||||
getUnreadCount?: () => Promise<number>;
|
||||
update?: (
|
||||
id: string,
|
||||
d: UpdateNotification
|
||||
) => Promise<Notification | null>;
|
||||
markAsRead?: (id: string) => Promise<Notification | null>;
|
||||
markManyAsRead?: (
|
||||
ids: ReadonlyArray<string>
|
||||
) => Promise<BulkOperationResult>;
|
||||
markAllAsRead?: () => Promise<BulkOperationResult>;
|
||||
dismiss?: (id: string) => Promise<Notification | null>;
|
||||
deleteFn?: (id: string) => Promise<boolean>;
|
||||
findGroupedByType?: () => Promise<GroupedNotifications>;
|
||||
deleteOldRead?: (n: number) => Promise<number>;
|
||||
}
|
||||
|
||||
function makeRepo(o: RepoOverrides = {}): INotificationRepository {
|
||||
return {
|
||||
create: o.create ?? (async () => baseNotification),
|
||||
findById: o.findById ?? (async () => baseNotification),
|
||||
findByRecipient: o.findByRecipient ?? (async () => [baseNotification]),
|
||||
getUnreadCount: o.getUnreadCount ?? (async () => 5),
|
||||
update: o.update ?? (async () => baseNotification),
|
||||
markAsRead: o.markAsRead ?? (async () => baseNotification),
|
||||
markManyAsRead:
|
||||
o.markManyAsRead ?? (async () => ({ count: 2, updated: 2 })),
|
||||
markAllAsRead:
|
||||
o.markAllAsRead ?? (async () => ({ count: 5, updated: 5 })),
|
||||
dismiss: o.dismiss ?? (async () => baseNotification),
|
||||
delete: o.deleteFn ?? (async () => true),
|
||||
findGroupedByType: o.findGroupedByType ?? (async () => ({})),
|
||||
deleteOldRead: o.deleteOldRead ?? (async () => 0),
|
||||
};
|
||||
}
|
||||
|
||||
function buildApp(
|
||||
repo: INotificationRepository,
|
||||
user: { id: string } | null = { id: "user:1" }
|
||||
) {
|
||||
const seen: NotificationsRouteEvent[] = [];
|
||||
const app = new OpenAPIHono<{
|
||||
Variables: {
|
||||
user: { id: string } | null;
|
||||
siteId?: string;
|
||||
};
|
||||
}>();
|
||||
app.use(async (c, next) => {
|
||||
c.set("user", user);
|
||||
c.set("siteId", "site:main");
|
||||
await next();
|
||||
});
|
||||
app.route(
|
||||
"/notifications",
|
||||
createNotificationsRoutes({
|
||||
notificationRepository: repo,
|
||||
emitEvent: (e) => seen.push(e),
|
||||
})
|
||||
);
|
||||
return { app, seen };
|
||||
}
|
||||
|
||||
describe("GET /notifications", () => {
|
||||
it("returns 401 without user", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeRepo(), null);
|
||||
const res = await app.request("/notifications");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
it("returns 200 with list", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeRepo());
|
||||
const res = await app.request("/notifications?limit=20");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.length).toBe(1);
|
||||
});
|
||||
it("forwards type / since query params to the repo", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let seenOpts: Record<string, unknown> | null = null;
|
||||
const { app } = buildApp(
|
||||
makeRepo({
|
||||
findByRecipient: async (
|
||||
_user?: string,
|
||||
_site?: string,
|
||||
opts?: Record<string, unknown>
|
||||
) => {
|
||||
seenOpts = (opts ?? null) as Record<string, unknown> | null;
|
||||
return [];
|
||||
},
|
||||
} as never)
|
||||
);
|
||||
const res = await app.request(
|
||||
"/notifications?type=friend_request&since=2026-01-01T00%3A00%3A00Z"
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(seenOpts?.type).toBe("friend_request");
|
||||
expect(seenOpts?.since).toBe("2026-01-01T00:00:00Z");
|
||||
});
|
||||
|
||||
it("marks hasMore=true when data.length == limit", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const many = Array.from({ length: 20 }, (_, i) => ({
|
||||
...baseNotification,
|
||||
id: `notification:${i}`,
|
||||
}));
|
||||
const { app } = buildApp(
|
||||
makeRepo({ findByRecipient: async () => many })
|
||||
);
|
||||
const res = await app.request("/notifications?limit=20");
|
||||
const body = await res.json();
|
||||
expect(body.hasMore).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /notifications/unread-count", () => {
|
||||
it("returns 401 without user", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeRepo(), null);
|
||||
const res = await app.request("/notifications/unread-count");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
it("returns the count", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeRepo());
|
||||
const res = await app.request("/notifications/unread-count");
|
||||
const body = await res.json();
|
||||
expect(body.count).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /notifications", () => {
|
||||
it("creates and emits", { timeout: 5000 }, async () => {
|
||||
const { app, seen } = buildApp(makeRepo());
|
||||
const res = await app.request("/notifications", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "friend_request",
|
||||
recipient: "user:1",
|
||||
content: "hi",
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(seen[0]?.type).toBe("notification:created");
|
||||
});
|
||||
it("rejects without user", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeRepo(), null);
|
||||
const res = await app.request("/notifications", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "friend_request",
|
||||
recipient: "user:1",
|
||||
content: "hi",
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /notifications/:id", () => {
|
||||
it("returns 200 when owner", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeRepo());
|
||||
const res = await app.request("/notifications/notification:1");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
it("returns 404 when missing", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeRepo({ findById: async () => null }));
|
||||
const res = await app.request("/notifications/notification:1");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
it("returns 403 when not owner", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(
|
||||
makeRepo({
|
||||
findById: async () => ({
|
||||
...baseNotification,
|
||||
recipient: "user:99",
|
||||
}),
|
||||
})
|
||||
);
|
||||
const res = await app.request("/notifications/notification:1");
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
it("returns 401 without user", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeRepo(), null);
|
||||
const res = await app.request("/notifications/notification:1");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /notifications/:id", () => {
|
||||
it("updates and returns 200", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeRepo());
|
||||
const res = await app.request("/notifications/notification:1", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ read: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
it("returns 404 if existing missing", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeRepo({ findById: async () => null }));
|
||||
const res = await app.request("/notifications/notification:1", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ read: true }),
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
it("returns 403 if not owner", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(
|
||||
makeRepo({
|
||||
findById: async () => ({
|
||||
...baseNotification,
|
||||
recipient: "user:99",
|
||||
}),
|
||||
})
|
||||
);
|
||||
const res = await app.request("/notifications/notification:1", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ read: true }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
it("returns 401 without user", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeRepo(), null);
|
||||
const res = await app.request("/notifications/notification:1", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ read: true }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
it("404 when update returns null", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeRepo({ update: async () => null }));
|
||||
const res = await app.request("/notifications/notification:1", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ read: true }),
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /notifications/:id/read", () => {
|
||||
it("marks read and emits", { timeout: 5000 }, async () => {
|
||||
const { app, seen } = buildApp(makeRepo());
|
||||
const res = await app.request("/notifications/notification:1/read", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(seen.find((e) => e.type === "notification:read")).toBeDefined();
|
||||
});
|
||||
it("404 when missing", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeRepo({ findById: async () => null }));
|
||||
const res = await app.request("/notifications/notification:1/read", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
it("403 when not owner", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(
|
||||
makeRepo({
|
||||
findById: async () => ({
|
||||
...baseNotification,
|
||||
recipient: "user:99",
|
||||
}),
|
||||
})
|
||||
);
|
||||
const res = await app.request("/notifications/notification:1/read", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
it("401 without user", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeRepo(), null);
|
||||
const res = await app.request("/notifications/notification:1/read", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
it("404 when markAsRead returns null", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeRepo({ markAsRead: async () => null }));
|
||||
const res = await app.request("/notifications/notification:1/read", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /notifications/read-many", () => {
|
||||
it("updates count", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeRepo());
|
||||
const res = await app.request("/notifications/read-many", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ids: ["notification:1", "notification:2"] }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.updated).toBe(2);
|
||||
});
|
||||
it("401 without user", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeRepo(), null);
|
||||
const res = await app.request("/notifications/read-many", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ids: ["notification:1"] }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /notifications/read-all", () => {
|
||||
it("updates all", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeRepo());
|
||||
const res = await app.request("/notifications/read-all", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
it("401 without user", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeRepo(), null);
|
||||
const res = await app.request("/notifications/read-all", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /notifications/:id/dismiss", () => {
|
||||
it("dismisses and emits", { timeout: 5000 }, async () => {
|
||||
const { app, seen } = buildApp(makeRepo());
|
||||
const res = await app.request("/notifications/notification:1/dismiss", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(
|
||||
seen.find((e) => e.type === "notification:dismissed")
|
||||
).toBeDefined();
|
||||
});
|
||||
it("404 when missing", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeRepo({ findById: async () => null }));
|
||||
const res = await app.request("/notifications/notification:1/dismiss", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
it("403 when not owner", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(
|
||||
makeRepo({
|
||||
findById: async () => ({
|
||||
...baseNotification,
|
||||
recipient: "user:99",
|
||||
}),
|
||||
})
|
||||
);
|
||||
const res = await app.request("/notifications/notification:1/dismiss", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
it("401 without user", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeRepo(), null);
|
||||
const res = await app.request("/notifications/notification:1/dismiss", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
it("404 when dismiss returns null", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeRepo({ dismiss: async () => null }));
|
||||
const res = await app.request("/notifications/notification:1/dismiss", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /notifications/:id", () => {
|
||||
it("deletes and emits", { timeout: 5000 }, async () => {
|
||||
const { app, seen } = buildApp(makeRepo());
|
||||
const res = await app.request("/notifications/notification:1", {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(
|
||||
seen.find((e) => e.type === "notification:deleted")
|
||||
).toBeDefined();
|
||||
});
|
||||
it("returns deleted=false when repo fails", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app } = buildApp(makeRepo({ deleteFn: async () => false }));
|
||||
const res = await app.request("/notifications/notification:1", {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.deleted).toBe(false);
|
||||
});
|
||||
it("404 when missing", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeRepo({ findById: async () => null }));
|
||||
const res = await app.request("/notifications/notification:1", {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
it("403 when not owner", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(
|
||||
makeRepo({
|
||||
findById: async () => ({
|
||||
...baseNotification,
|
||||
recipient: "user:99",
|
||||
}),
|
||||
})
|
||||
);
|
||||
const res = await app.request("/notifications/notification:1", {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
it("401 without user", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeRepo(), null);
|
||||
const res = await app.request("/notifications/notification:1", {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createNotificationsRoutes — no emitter", () => {
|
||||
it("works without an emitEvent callback", { timeout: 5000 }, async () => {
|
||||
const app = new OpenAPIHono<{
|
||||
Variables: { user: { id: string } | null };
|
||||
}>();
|
||||
app.use(async (c, next) => {
|
||||
c.set("user", { id: "user:1" });
|
||||
await next();
|
||||
});
|
||||
app.route(
|
||||
"/notifications",
|
||||
createNotificationsRoutes({
|
||||
notificationRepository: makeRepo(),
|
||||
})
|
||||
);
|
||||
const res = await app.request("/notifications", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "system",
|
||||
recipient: "user:1",
|
||||
content: "hi",
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,766 @@
|
||||
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
||||
import {
|
||||
NOTIFICATIONS_PLUGIN_CODES,
|
||||
NotificationsPluginError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { getNotificationsInstruments } from "../../otel/instruments.js";
|
||||
import {
|
||||
BulkOperationResponseSchema,
|
||||
CreateNotificationSchema,
|
||||
MarkManyAsReadSchema,
|
||||
NotificationIdParamSchema,
|
||||
NotificationListResponseSchema,
|
||||
NotificationQuerySchema,
|
||||
NotificationSchema,
|
||||
UnreadCountResponseSchema,
|
||||
UpdateNotificationSchema,
|
||||
} from "../../schemas/notification-schemas.js";
|
||||
import type { NotificationsRoutesConfig } from "../../types/types.js";
|
||||
|
||||
type AuthedUser = { id: string } & Record<string, unknown>;
|
||||
|
||||
/** Hono `Variables` for the notifications route module. */
|
||||
export interface NotificationsRoutesVariables {
|
||||
user: AuthedUser | null | undefined;
|
||||
siteId?: string;
|
||||
}
|
||||
|
||||
const ErrorBodySchema = z.object({ error: z.string(), code: z.string() });
|
||||
|
||||
function errorBody(
|
||||
code: string,
|
||||
error: string
|
||||
): { error: string; code: string } {
|
||||
return { error, code };
|
||||
}
|
||||
|
||||
function getUser(c: {
|
||||
get: (key: "user") => AuthedUser | null | undefined;
|
||||
}): AuthedUser {
|
||||
const user = c.get("user");
|
||||
if (user === null || user === undefined) {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.UNAUTHENTICATED,
|
||||
"Authentication required"
|
||||
);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
function ownerMatches(recipient: unknown, userId: string): boolean {
|
||||
const rec = String(recipient);
|
||||
const uid = String(userId);
|
||||
return rec.includes(uid) || uid.includes(rec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the notifications plugin's `/notifications` route bundle.
|
||||
*
|
||||
* The host mounts the returned `OpenAPIHono` under the configured
|
||||
* `basePath` (default `/notifications`). Authentication is required
|
||||
* on every route; missing `c.get("user")` → `401`. Recipients are
|
||||
* resolved from `c.get("user").id`; the site is resolved from
|
||||
* `c.get("siteId")` (host-set, optional).
|
||||
*
|
||||
* Domain events are forwarded to the host's `eventBus` via the
|
||||
* injected `emitEvent` callback. Route handlers never reference the
|
||||
* bus directly — this matches the api-plugin-events pattern.
|
||||
*/
|
||||
export function createNotificationsRoutes(
|
||||
config: NotificationsRoutesConfig
|
||||
): OpenAPIHono<{ Variables: NotificationsRoutesVariables }> {
|
||||
const app = new OpenAPIHono<{ Variables: NotificationsRoutesVariables }>();
|
||||
const repo = config.notificationRepository;
|
||||
const emit = config.emitEvent;
|
||||
const inst = getNotificationsInstruments();
|
||||
|
||||
// GET / — list
|
||||
app.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/",
|
||||
tags: ["notifications"],
|
||||
summary: "List notifications",
|
||||
request: { query: NotificationQuerySchema },
|
||||
responses: {
|
||||
200: {
|
||||
description: "List",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: NotificationListResponseSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "Unauthorized",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
let user: AuthedUser;
|
||||
try {
|
||||
user = getUser(c);
|
||||
} catch (err) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
(err as NotificationsPluginError).code,
|
||||
(err as Error).message
|
||||
),
|
||||
401
|
||||
);
|
||||
}
|
||||
const query = c.req.valid("query");
|
||||
const siteId = c.get("siteId");
|
||||
const opts: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
unreadOnly?: boolean;
|
||||
type?: string;
|
||||
since?: string;
|
||||
} = {
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
unreadOnly: query.unreadOnly,
|
||||
};
|
||||
if (query.type !== undefined) opts.type = query.type;
|
||||
if (query.since !== undefined) opts.since = query.since;
|
||||
const data = await repo.findByRecipient(user.id, siteId, opts);
|
||||
return c.json(
|
||||
{
|
||||
data,
|
||||
total: data.length,
|
||||
hasMore: data.length === query.limit,
|
||||
},
|
||||
200
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// GET /unread-count
|
||||
app.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/unread-count",
|
||||
tags: ["notifications"],
|
||||
summary: "Unread count",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Count",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: UnreadCountResponseSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "Unauthorized",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
let user: AuthedUser;
|
||||
try {
|
||||
user = getUser(c);
|
||||
} catch (err) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
(err as NotificationsPluginError).code,
|
||||
(err as Error).message
|
||||
),
|
||||
401
|
||||
);
|
||||
}
|
||||
const siteId = c.get("siteId");
|
||||
const count = await repo.getUnreadCount(user.id, siteId);
|
||||
return c.json({ count }, 200);
|
||||
}
|
||||
);
|
||||
|
||||
// POST / — create
|
||||
app.openapi(
|
||||
createRoute({
|
||||
method: "post",
|
||||
path: "/",
|
||||
tags: ["notifications"],
|
||||
summary: "Create notification",
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: CreateNotificationSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: "Created",
|
||||
content: {
|
||||
"application/json": { schema: NotificationSchema },
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "Unauthorized",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
try {
|
||||
getUser(c);
|
||||
} catch (err) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
(err as NotificationsPluginError).code,
|
||||
(err as Error).message
|
||||
),
|
||||
401
|
||||
);
|
||||
}
|
||||
const data = c.req.valid("json");
|
||||
const created = await repo.create(data);
|
||||
inst.notificationsCreatedTotal.add(1, { type: data.type });
|
||||
emit?.({
|
||||
type: "notification:created",
|
||||
payload: {
|
||||
notificationId: String(created.id),
|
||||
recipientId: String(created.recipient),
|
||||
type: String(created.type),
|
||||
},
|
||||
});
|
||||
return c.json(created, 201);
|
||||
}
|
||||
);
|
||||
|
||||
// GET /:id
|
||||
app.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/{id}",
|
||||
tags: ["notifications"],
|
||||
summary: "Get notification",
|
||||
request: { params: NotificationIdParamSchema },
|
||||
responses: {
|
||||
200: {
|
||||
description: "Notification",
|
||||
content: {
|
||||
"application/json": { schema: NotificationSchema },
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "Unauthorized",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description: "Forbidden",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Not found",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
let user: AuthedUser;
|
||||
try {
|
||||
user = getUser(c);
|
||||
} catch (err) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
(err as NotificationsPluginError).code,
|
||||
(err as Error).message
|
||||
),
|
||||
401
|
||||
);
|
||||
}
|
||||
const { id } = c.req.valid("param");
|
||||
const n = await repo.findById(id);
|
||||
if (n === null) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
NOTIFICATIONS_PLUGIN_CODES.NOTIFICATION_NOT_FOUND,
|
||||
"Notification not found"
|
||||
),
|
||||
404
|
||||
);
|
||||
}
|
||||
if (!ownerMatches(n.recipient, user.id)) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
NOTIFICATIONS_PLUGIN_CODES.FORBIDDEN,
|
||||
"Access denied"
|
||||
),
|
||||
403
|
||||
);
|
||||
}
|
||||
return c.json(n, 200);
|
||||
}
|
||||
);
|
||||
|
||||
// PATCH /:id
|
||||
app.openapi(
|
||||
createRoute({
|
||||
method: "patch",
|
||||
path: "/{id}",
|
||||
tags: ["notifications"],
|
||||
summary: "Update notification",
|
||||
request: {
|
||||
params: NotificationIdParamSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: UpdateNotificationSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Updated",
|
||||
content: {
|
||||
"application/json": { schema: NotificationSchema },
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "Unauthorized",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description: "Forbidden",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Not found",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
let user: AuthedUser;
|
||||
try {
|
||||
user = getUser(c);
|
||||
} catch (err) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
(err as NotificationsPluginError).code,
|
||||
(err as Error).message
|
||||
),
|
||||
401
|
||||
);
|
||||
}
|
||||
const { id } = c.req.valid("param");
|
||||
const body = c.req.valid("json");
|
||||
const existing = await repo.findById(id);
|
||||
if (existing === null) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
NOTIFICATIONS_PLUGIN_CODES.NOTIFICATION_NOT_FOUND,
|
||||
"Notification not found"
|
||||
),
|
||||
404
|
||||
);
|
||||
}
|
||||
if (!ownerMatches(existing.recipient, user.id)) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
NOTIFICATIONS_PLUGIN_CODES.FORBIDDEN,
|
||||
"Access denied"
|
||||
),
|
||||
403
|
||||
);
|
||||
}
|
||||
const updated = await repo.update(id, body);
|
||||
if (updated === null) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
NOTIFICATIONS_PLUGIN_CODES.NOTIFICATION_UPDATE_FAILED,
|
||||
"Update failed"
|
||||
),
|
||||
404
|
||||
);
|
||||
}
|
||||
return c.json(updated, 200);
|
||||
}
|
||||
);
|
||||
|
||||
// POST /:id/read
|
||||
app.openapi(
|
||||
createRoute({
|
||||
method: "post",
|
||||
path: "/{id}/read",
|
||||
tags: ["notifications"],
|
||||
summary: "Mark as read",
|
||||
request: { params: NotificationIdParamSchema },
|
||||
responses: {
|
||||
200: {
|
||||
description: "Marked",
|
||||
content: {
|
||||
"application/json": { schema: NotificationSchema },
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "Unauthorized",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description: "Forbidden",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Not found",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
let user: AuthedUser;
|
||||
try {
|
||||
user = getUser(c);
|
||||
} catch (err) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
(err as NotificationsPluginError).code,
|
||||
(err as Error).message
|
||||
),
|
||||
401
|
||||
);
|
||||
}
|
||||
const { id } = c.req.valid("param");
|
||||
const existing = await repo.findById(id);
|
||||
if (existing === null) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
NOTIFICATIONS_PLUGIN_CODES.NOTIFICATION_NOT_FOUND,
|
||||
"Notification not found"
|
||||
),
|
||||
404
|
||||
);
|
||||
}
|
||||
if (!ownerMatches(existing.recipient, user.id)) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
NOTIFICATIONS_PLUGIN_CODES.FORBIDDEN,
|
||||
"Access denied"
|
||||
),
|
||||
403
|
||||
);
|
||||
}
|
||||
const n = await repo.markAsRead(id);
|
||||
if (n === null) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
NOTIFICATIONS_PLUGIN_CODES.NOTIFICATION_NOT_FOUND,
|
||||
"Notification not found"
|
||||
),
|
||||
404
|
||||
);
|
||||
}
|
||||
inst.notificationsReadTotal.add(1);
|
||||
emit?.({
|
||||
type: "notification:read",
|
||||
payload: {
|
||||
notificationId: String(n.id),
|
||||
recipientId: String(n.recipient),
|
||||
},
|
||||
});
|
||||
return c.json(n, 200);
|
||||
}
|
||||
);
|
||||
|
||||
// POST /read-many
|
||||
app.openapi(
|
||||
createRoute({
|
||||
method: "post",
|
||||
path: "/read-many",
|
||||
tags: ["notifications"],
|
||||
summary: "Mark many as read",
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": { schema: MarkManyAsReadSchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Updated count",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: BulkOperationResponseSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "Unauthorized",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
try {
|
||||
getUser(c);
|
||||
} catch (err) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
(err as NotificationsPluginError).code,
|
||||
(err as Error).message
|
||||
),
|
||||
401
|
||||
);
|
||||
}
|
||||
const { ids } = c.req.valid("json");
|
||||
const result = await repo.markManyAsRead(ids);
|
||||
inst.notificationsReadTotal.add(result.updated);
|
||||
return c.json({ updated: result.updated }, 200);
|
||||
}
|
||||
);
|
||||
|
||||
// POST /read-all
|
||||
app.openapi(
|
||||
createRoute({
|
||||
method: "post",
|
||||
path: "/read-all",
|
||||
tags: ["notifications"],
|
||||
summary: "Mark all as read",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Updated count",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: BulkOperationResponseSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "Unauthorized",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
let user: AuthedUser;
|
||||
try {
|
||||
user = getUser(c);
|
||||
} catch (err) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
(err as NotificationsPluginError).code,
|
||||
(err as Error).message
|
||||
),
|
||||
401
|
||||
);
|
||||
}
|
||||
const siteId = c.get("siteId");
|
||||
const result = await repo.markAllAsRead(user.id, siteId);
|
||||
inst.notificationsReadTotal.add(result.updated);
|
||||
return c.json({ updated: result.updated }, 200);
|
||||
}
|
||||
);
|
||||
|
||||
// POST /:id/dismiss
|
||||
app.openapi(
|
||||
createRoute({
|
||||
method: "post",
|
||||
path: "/{id}/dismiss",
|
||||
tags: ["notifications"],
|
||||
summary: "Dismiss notification",
|
||||
request: { params: NotificationIdParamSchema },
|
||||
responses: {
|
||||
200: {
|
||||
description: "Dismissed",
|
||||
content: {
|
||||
"application/json": { schema: NotificationSchema },
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "Unauthorized",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description: "Forbidden",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Not found",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
let user: AuthedUser;
|
||||
try {
|
||||
user = getUser(c);
|
||||
} catch (err) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
(err as NotificationsPluginError).code,
|
||||
(err as Error).message
|
||||
),
|
||||
401
|
||||
);
|
||||
}
|
||||
const { id } = c.req.valid("param");
|
||||
const existing = await repo.findById(id);
|
||||
if (existing === null) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
NOTIFICATIONS_PLUGIN_CODES.NOTIFICATION_NOT_FOUND,
|
||||
"Notification not found"
|
||||
),
|
||||
404
|
||||
);
|
||||
}
|
||||
if (!ownerMatches(existing.recipient, user.id)) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
NOTIFICATIONS_PLUGIN_CODES.FORBIDDEN,
|
||||
"Access denied"
|
||||
),
|
||||
403
|
||||
);
|
||||
}
|
||||
const n = await repo.dismiss(id);
|
||||
if (n === null) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
NOTIFICATIONS_PLUGIN_CODES.NOTIFICATION_NOT_FOUND,
|
||||
"Notification not found"
|
||||
),
|
||||
404
|
||||
);
|
||||
}
|
||||
emit?.({
|
||||
type: "notification:dismissed",
|
||||
payload: {
|
||||
notificationId: String(n.id),
|
||||
recipientId: String(n.recipient),
|
||||
},
|
||||
});
|
||||
return c.json(n, 200);
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /:id
|
||||
app.openapi(
|
||||
createRoute({
|
||||
method: "delete",
|
||||
path: "/{id}",
|
||||
tags: ["notifications"],
|
||||
summary: "Delete notification",
|
||||
request: { params: NotificationIdParamSchema },
|
||||
responses: {
|
||||
200: {
|
||||
description: "Deleted",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({ deleted: z.boolean() }),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "Unauthorized",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description: "Forbidden",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Not found",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
let user: AuthedUser;
|
||||
try {
|
||||
user = getUser(c);
|
||||
} catch (err) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
(err as NotificationsPluginError).code,
|
||||
(err as Error).message
|
||||
),
|
||||
401
|
||||
);
|
||||
}
|
||||
const { id } = c.req.valid("param");
|
||||
const existing = await repo.findById(id);
|
||||
if (existing === null) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
NOTIFICATIONS_PLUGIN_CODES.NOTIFICATION_NOT_FOUND,
|
||||
"Notification not found"
|
||||
),
|
||||
404
|
||||
);
|
||||
}
|
||||
if (!ownerMatches(existing.recipient, user.id)) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
NOTIFICATIONS_PLUGIN_CODES.FORBIDDEN,
|
||||
"Access denied"
|
||||
),
|
||||
403
|
||||
);
|
||||
}
|
||||
const deleted = await repo.delete(id);
|
||||
if (deleted) {
|
||||
inst.notificationsDeletedTotal.add(1);
|
||||
emit?.({
|
||||
type: "notification:deleted",
|
||||
payload: {
|
||||
notificationId: String(existing.id),
|
||||
recipientId: String(existing.recipient),
|
||||
},
|
||||
});
|
||||
}
|
||||
return c.json({ deleted }, 200);
|
||||
}
|
||||
);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createPushRoutes, type PushRoutesVariables } from "./push-routes.js";
|
||||
@@ -0,0 +1,78 @@
|
||||
# `routes/push-routes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Hono OpenAPI route module exposing the `/push/*` surface for device-
|
||||
token registration and APN delivery. Five endpoints cover register /
|
||||
unregister / list device tokens plus send / send-bulk push delivery.
|
||||
The module is constructed by `createPushRoutes(...)` and mounted by
|
||||
the plugin under the configured `pushBasePath`. Delivery endpoints
|
||||
short-circuit to `503` when the APN service is not registered
|
||||
(`enablePush: false`); the registration endpoints remain available so
|
||||
mobile clients can register tokens against a backend that opts to
|
||||
process them later (queued / batched).
|
||||
|
||||
## Features
|
||||
|
||||
- Per-route Zod validation through `@hono/zod-openapi`.
|
||||
- Authentication guard via `c.get("user")` (host-set).
|
||||
- Ownership guard on `DELETE /tokens/{id}` — only the registering
|
||||
user may unregister their own devices; mismatch returns `403`.
|
||||
- Idempotent unregister — deleting a non-existent device returns
|
||||
`200` with a "already absent" message, matching the upstream's
|
||||
retry-safe contract.
|
||||
- OTel emission — `deviceTokensRegisteredTotal{platform}`,
|
||||
`pushSentTotal{platform}`, and `pushFailedTotal{reason}` are
|
||||
incremented from the route handler.
|
||||
- Event-bus forwarding — `notification:device:registered`,
|
||||
`notification:device:unregistered`, `notification:push:sent`, and
|
||||
`notification:push:failed` route through `emitEvent`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createPushRoutes } from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
const routes = createPushRoutes({
|
||||
deviceTokenRepository,
|
||||
apnService,
|
||||
maxBulkRecipients: 500,
|
||||
emitEvent: (e) => eventBus.emit(e.type, e.payload),
|
||||
});
|
||||
app.route("/push", routes);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Disable push delivery while still accepting token registrations:
|
||||
|
||||
```ts
|
||||
const routes = createPushRoutes({
|
||||
deviceTokenRepository,
|
||||
apnService: null, // /send and /send-bulk return 503
|
||||
maxBulkRecipients: 0,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createPushRoutes(config: PushRoutesConfig)` →
|
||||
`OpenAPIHono<{ Variables: PushRoutesVariables }>`
|
||||
- `PushRoutesVariables` — `{ user }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`POST /send` resolves the destination user's active device tokens via
|
||||
`DeviceTokenRepository.findActiveForUser(...)`, fans out one APN call
|
||||
per device, increments OTel counters per result, and emits a single
|
||||
`notification:push:sent` event per successful device. The bulk
|
||||
endpoint caps the recipient list by `maxBulkRecipients` so callers
|
||||
cannot bypass the boundary cap defined in
|
||||
`SendBulkPushNotificationSchema`. Per-device failures inside a bulk
|
||||
send do NOT short-circuit the loop — every user gets a delivery
|
||||
attempt and the aggregate `sent` / `failed` numbers return in the
|
||||
response.
|
||||
@@ -0,0 +1,529 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
NOTIFICATIONS_PLUGIN_CODES,
|
||||
NotificationsPluginError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { resetNotificationsInstruments } from "../../otel/instruments.js";
|
||||
import type { IDeviceTokenRepository } from "../../repositories/device-token-repository/device-token-repository.types.js";
|
||||
import { ApnConfigSchema } from "../../schemas/options.js";
|
||||
import type {
|
||||
DeviceToken,
|
||||
RegisterDeviceToken,
|
||||
} from "../../schemas/push-schemas.js";
|
||||
import { ApnService } from "../../services/apn-service/apn-service.js";
|
||||
import { MemoryApnTransport } from "../../transport/apn-transport/apn-transport.js";
|
||||
import type { NotificationsRouteEvent } from "../../types/types.js";
|
||||
import { createPushRoutes } from "./push-routes.js";
|
||||
|
||||
afterEach(() => {
|
||||
resetNotificationsInstruments();
|
||||
});
|
||||
|
||||
const tokenConfig = ApnConfigSchema.parse({
|
||||
token: { key: "k", keyId: "i", teamId: "t" },
|
||||
defaultTopic: "com.example.app",
|
||||
});
|
||||
|
||||
const baseToken: DeviceToken = {
|
||||
id: "device_token:1",
|
||||
user_id: "user:1",
|
||||
device_id: "dev1",
|
||||
token: "deadbeef",
|
||||
platform: "ios",
|
||||
environment: "sandbox",
|
||||
is_active: true,
|
||||
last_used: "2026-01-01T00:00:00Z",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
interface TokenOverrides {
|
||||
register?: (
|
||||
userId: string,
|
||||
data: RegisterDeviceToken
|
||||
) => Promise<DeviceToken>;
|
||||
findById?: (id: string) => Promise<DeviceToken | null>;
|
||||
findByToken?: (token: string) => Promise<DeviceToken | null>;
|
||||
findActiveForUser?: (userId: string) => Promise<DeviceToken[]>;
|
||||
deactivate?: (id: string) => Promise<boolean>;
|
||||
deactivateByToken?: (token: string) => Promise<boolean>;
|
||||
touchLastUsed?: (token: string) => Promise<void>;
|
||||
cleanup?: (n: number) => Promise<number>;
|
||||
}
|
||||
|
||||
function makeTokenRepo(o: TokenOverrides = {}): IDeviceTokenRepository {
|
||||
return {
|
||||
register: o.register ?? (async () => baseToken),
|
||||
findById: o.findById ?? (async () => baseToken),
|
||||
findByToken: o.findByToken ?? (async () => baseToken),
|
||||
findActiveForUser: o.findActiveForUser ?? (async () => [baseToken]),
|
||||
deactivate: o.deactivate ?? (async () => true),
|
||||
deactivateByToken: o.deactivateByToken ?? (async () => true),
|
||||
touchLastUsed: o.touchLastUsed ?? (async () => undefined),
|
||||
cleanup: o.cleanup ?? (async () => 0),
|
||||
};
|
||||
}
|
||||
|
||||
function makeApn(
|
||||
failurePlan?: Map<string, { status?: number; reason?: string }>
|
||||
) {
|
||||
return new ApnService({
|
||||
config: tokenConfig,
|
||||
transport: new MemoryApnTransport(tokenConfig, failurePlan),
|
||||
});
|
||||
}
|
||||
|
||||
function buildApp(
|
||||
tokens: IDeviceTokenRepository,
|
||||
apn: ApnService | null,
|
||||
user: { id: string } | null = { id: "user:1" },
|
||||
maxBulk = 1000
|
||||
) {
|
||||
const seen: NotificationsRouteEvent[] = [];
|
||||
const app = new OpenAPIHono<{
|
||||
Variables: { user: { id: string } | null };
|
||||
}>();
|
||||
app.use(async (c, next) => {
|
||||
c.set("user", user);
|
||||
await next();
|
||||
});
|
||||
app.route(
|
||||
"/push",
|
||||
createPushRoutes({
|
||||
deviceTokenRepository: tokens,
|
||||
apnService: apn,
|
||||
maxBulkRecipients: maxBulk,
|
||||
emitEvent: (e) => seen.push(e),
|
||||
})
|
||||
);
|
||||
return { app, seen };
|
||||
}
|
||||
|
||||
describe("POST /push/tokens", () => {
|
||||
it("registers and emits", { timeout: 5000 }, async () => {
|
||||
const { app, seen } = buildApp(makeTokenRepo(), null);
|
||||
const res = await app.request("/push/tokens", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ deviceToken: "abc" }),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(
|
||||
seen.find((e) => e.type === "notification:device:registered")
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns 409 on duplicate", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(
|
||||
makeTokenRepo({
|
||||
register: async () => {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.DEVICE_TOKEN_DUPLICATE,
|
||||
"dup"
|
||||
);
|
||||
},
|
||||
}),
|
||||
null
|
||||
);
|
||||
const res = await app.request("/push/tokens", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ deviceToken: "abc" }),
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("propagates non-duplicate plugin error as 500", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app } = buildApp(
|
||||
makeTokenRepo({
|
||||
register: async () => {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.DEVICE_TOKEN_CREATE_FAILED,
|
||||
"boom"
|
||||
);
|
||||
},
|
||||
}),
|
||||
null
|
||||
);
|
||||
const res = await app.request("/push/tokens", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ deviceToken: "abc" }),
|
||||
});
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("401 without user", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeTokenRepo(), null, null);
|
||||
const res = await app.request("/push/tokens", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ deviceToken: "abc" }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /push/tokens/:id", () => {
|
||||
it("unregisters by id", { timeout: 5000 }, async () => {
|
||||
const { app, seen } = buildApp(makeTokenRepo(), null);
|
||||
const res = await app.request("/push/tokens/device_token:1", {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(
|
||||
seen.find((e) => e.type === "notification:device:unregistered")
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("falls back to findByToken when findById null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app } = buildApp(
|
||||
makeTokenRepo({ findById: async () => null }),
|
||||
null
|
||||
);
|
||||
const res = await app.request("/push/tokens/deadbeef", {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("idempotent when both lookups return null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app } = buildApp(
|
||||
makeTokenRepo({
|
||||
findById: async () => null,
|
||||
findByToken: async () => null,
|
||||
}),
|
||||
null
|
||||
);
|
||||
const res = await app.request("/push/tokens/unknown", {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("403 when not owner", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(
|
||||
makeTokenRepo({
|
||||
findById: async () => ({ ...baseToken, user_id: "user:99" }),
|
||||
}),
|
||||
null
|
||||
);
|
||||
const res = await app.request("/push/tokens/device_token:1", {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("401 without user", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeTokenRepo(), null, null);
|
||||
const res = await app.request("/push/tokens/device_token:1", {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /push/tokens", () => {
|
||||
it("lists active tokens", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeTokenRepo(), null);
|
||||
const res = await app.request("/push/tokens");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.length).toBe(1);
|
||||
});
|
||||
it("401 without user", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeTokenRepo(), null, null);
|
||||
const res = await app.request("/push/tokens");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /push/send", () => {
|
||||
it("503 when APN disabled", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeTokenRepo(), null);
|
||||
const res = await app.request("/push/send", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ userId: "user:1", title: "T", body: "B" }),
|
||||
});
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it("401 without user", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeTokenRepo(), makeApn(), null);
|
||||
const res = await app.request("/push/send", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ userId: "user:1", title: "T", body: "B" }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns sent=1 on successful delivery", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app, seen } = buildApp(makeTokenRepo(), makeApn());
|
||||
const res = await app.request("/push/send", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ userId: "user:1", title: "T", body: "B" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.sent).toBe(1);
|
||||
expect(
|
||||
seen.find((e) => e.type === "notification:push:sent")
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("forwards data / badge / sound / category to APN service", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app } = buildApp(makeTokenRepo(), makeApn());
|
||||
const res = await app.request("/push/send", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
userId: "user:1",
|
||||
title: "T",
|
||||
body: "B",
|
||||
data: { k: "v" },
|
||||
badge: 3,
|
||||
sound: "ping.aiff",
|
||||
category: "msg",
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("emits push:failed on bad token", { timeout: 5000 }, async () => {
|
||||
const plan = new Map([
|
||||
["deadbeef", { status: 410, reason: "BadDeviceToken" }],
|
||||
]);
|
||||
const { app, seen } = buildApp(makeTokenRepo(), makeApn(plan));
|
||||
const res = await app.request("/push/send", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ userId: "user:1", title: "T", body: "B" }),
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.failed).toBe(1);
|
||||
expect(
|
||||
seen.find((e) => e.type === "notification:push:failed")
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns sent=0/failed=0 when user has no devices", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app } = buildApp(
|
||||
makeTokenRepo({ findActiveForUser: async () => [] }),
|
||||
makeApn()
|
||||
);
|
||||
const res = await app.request("/push/send", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
userId: "user:42",
|
||||
title: "T",
|
||||
body: "B",
|
||||
}),
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.sent).toBe(0);
|
||||
expect(body.failed).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /push/send-bulk", () => {
|
||||
it("503 when APN disabled", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeTokenRepo(), null);
|
||||
const res = await app.request("/push/send-bulk", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
userIds: ["user:1"],
|
||||
title: "T",
|
||||
body: "B",
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it("401 without user", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeTokenRepo(), makeApn(), null);
|
||||
const res = await app.request("/push/send-bulk", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
userIds: ["user:1"],
|
||||
title: "T",
|
||||
body: "B",
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("aggregates across users", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeTokenRepo(), makeApn());
|
||||
const res = await app.request("/push/send-bulk", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
userIds: ["user:1", "user:2"],
|
||||
title: "T",
|
||||
body: "B",
|
||||
}),
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.totalUsers).toBe(2);
|
||||
expect(body.sent).toBe(2);
|
||||
});
|
||||
|
||||
it("caps userIds at maxBulkRecipients", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(
|
||||
makeTokenRepo(),
|
||||
makeApn(),
|
||||
{ id: "user:1" },
|
||||
1
|
||||
);
|
||||
const res = await app.request("/push/send-bulk", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
userIds: ["user:1", "user:2", "user:3"],
|
||||
title: "T",
|
||||
body: "B",
|
||||
}),
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.totalUsers).toBe(1);
|
||||
});
|
||||
|
||||
it("skips users with no active devices", { timeout: 5000 }, async () => {
|
||||
let i = 0;
|
||||
const repo = makeTokenRepo({
|
||||
findActiveForUser: async () => (i++ === 0 ? [baseToken] : []),
|
||||
});
|
||||
const { app } = buildApp(repo, makeApn());
|
||||
const res = await app.request("/push/send-bulk", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
userIds: ["user:1", "user:2"],
|
||||
title: "T",
|
||||
body: "B",
|
||||
}),
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.sent).toBe(1);
|
||||
});
|
||||
|
||||
it("counts failures from bad devices", { timeout: 5000 }, async () => {
|
||||
const plan = new Map([
|
||||
["deadbeef", { status: 410, reason: "BadDeviceToken" }],
|
||||
]);
|
||||
const { app } = buildApp(makeTokenRepo(), makeApn(plan));
|
||||
const res = await app.request("/push/send-bulk", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
userIds: ["user:1"],
|
||||
title: "T",
|
||||
body: "B",
|
||||
}),
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.failed).toBe(1);
|
||||
});
|
||||
|
||||
it("forwards data on bulk send", { timeout: 5000 }, async () => {
|
||||
const { app } = buildApp(makeTokenRepo(), makeApn());
|
||||
const res = await app.request("/push/send-bulk", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
userIds: ["user:1"],
|
||||
title: "T",
|
||||
body: "B",
|
||||
data: { k: "v" },
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("counts bulk failures with no reason → unknown", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Failure plan with status but no reason → mapResponse returns
|
||||
// "APN status N" as error but no reason field, so the route's
|
||||
// `result.errors?.[0]?.reason ?? "unknown"` exercises the
|
||||
// fallback.
|
||||
const plan = new Map([["deadbeef", { status: 400 }]]);
|
||||
const { app } = buildApp(makeTokenRepo(), makeApn(plan));
|
||||
const res = await app.request("/push/send-bulk", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
userIds: ["user:1"],
|
||||
title: "T",
|
||||
body: "B",
|
||||
}),
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.failed).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /push/send — no-reason failure", () => {
|
||||
it("falls back reason → unknown when APN returns failure without reason", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plan = new Map([["deadbeef", { status: 400 }]]);
|
||||
const { app } = buildApp(makeTokenRepo(), makeApn(plan));
|
||||
const res = await app.request("/push/send", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
userId: "user:1",
|
||||
title: "T",
|
||||
body: "B",
|
||||
}),
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.failed).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPushRoutes — no emitter", () => {
|
||||
it("works without an emitEvent callback", { timeout: 5000 }, async () => {
|
||||
const app = new OpenAPIHono<{
|
||||
Variables: { user: { id: string } | null };
|
||||
}>();
|
||||
app.use(async (c, next) => {
|
||||
c.set("user", { id: "user:1" });
|
||||
await next();
|
||||
});
|
||||
app.route(
|
||||
"/push",
|
||||
createPushRoutes({
|
||||
deviceTokenRepository: makeTokenRepo(),
|
||||
apnService: null,
|
||||
maxBulkRecipients: 100,
|
||||
})
|
||||
);
|
||||
const res = await app.request("/push/tokens", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ deviceToken: "abc" }),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,516 @@
|
||||
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
||||
import {
|
||||
NOTIFICATIONS_PLUGIN_CODES,
|
||||
NotificationsPluginError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { getNotificationsInstruments } from "../../otel/instruments.js";
|
||||
import {
|
||||
DeviceTokenIdParamSchema,
|
||||
RegisterDeviceTokenResponseSchema,
|
||||
RegisterDeviceTokenSchema,
|
||||
SendBulkPushNotificationSchema,
|
||||
SendBulkPushResponseSchema,
|
||||
SendPushNotificationSchema,
|
||||
SendPushResponseSchema,
|
||||
UnregisterDeviceTokenResponseSchema,
|
||||
} from "../../schemas/push-schemas.js";
|
||||
import type { PushRoutesConfig } from "../../types/types.js";
|
||||
|
||||
type AuthedUser = { id: string } & Record<string, unknown>;
|
||||
|
||||
export interface PushRoutesVariables {
|
||||
user: AuthedUser | null | undefined;
|
||||
}
|
||||
|
||||
const ErrorBodySchema = z.object({ error: z.string(), code: z.string() });
|
||||
|
||||
function errorBody(
|
||||
code: string,
|
||||
error: string
|
||||
): { error: string; code: string } {
|
||||
return { error, code };
|
||||
}
|
||||
|
||||
function getUser(c: {
|
||||
get: (key: "user") => AuthedUser | null | undefined;
|
||||
}): AuthedUser {
|
||||
const user = c.get("user");
|
||||
if (user === null || user === undefined) {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.UNAUTHENTICATED,
|
||||
"Authentication required"
|
||||
);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the notifications plugin's `/push` route bundle.
|
||||
*
|
||||
* Six endpoints: register / unregister device token, list active
|
||||
* tokens for current user, send / send-bulk push notifications, and a
|
||||
* test endpoint. Send endpoints return `503` when the APN service is
|
||||
* not registered (`enablePush: false`).
|
||||
*/
|
||||
export function createPushRoutes(
|
||||
config: PushRoutesConfig
|
||||
): OpenAPIHono<{ Variables: PushRoutesVariables }> {
|
||||
const app = new OpenAPIHono<{ Variables: PushRoutesVariables }>();
|
||||
const tokens = config.deviceTokenRepository;
|
||||
const apn = config.apnService;
|
||||
const emit = config.emitEvent;
|
||||
const inst = getNotificationsInstruments();
|
||||
const maxBulk = config.maxBulkRecipients;
|
||||
|
||||
// POST /tokens — register
|
||||
app.openapi(
|
||||
createRoute({
|
||||
method: "post",
|
||||
path: "/tokens",
|
||||
tags: ["push"],
|
||||
summary: "Register device token",
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: RegisterDeviceTokenSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: "Registered",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: RegisterDeviceTokenResponseSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "Unauthorized",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description: "Duplicate token",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
let user: AuthedUser;
|
||||
try {
|
||||
user = getUser(c);
|
||||
} catch (err) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
(err as NotificationsPluginError).code,
|
||||
(err as Error).message
|
||||
),
|
||||
401
|
||||
);
|
||||
}
|
||||
const body = c.req.valid("json");
|
||||
try {
|
||||
const created = await tokens.register(user.id, body);
|
||||
inst.deviceTokensRegisteredTotal.add(1, {
|
||||
platform: created.platform,
|
||||
});
|
||||
emit?.({
|
||||
type: "notification:device:registered",
|
||||
payload: {
|
||||
userId: String(user.id),
|
||||
deviceTokenId: String(created.id),
|
||||
platform: created.platform,
|
||||
},
|
||||
});
|
||||
return c.json(
|
||||
{
|
||||
message: "Device registered successfully",
|
||||
deviceTokenId: String(created.id),
|
||||
},
|
||||
201
|
||||
);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof NotificationsPluginError &&
|
||||
err.code ===
|
||||
NOTIFICATIONS_PLUGIN_CODES.DEVICE_TOKEN_DUPLICATE
|
||||
) {
|
||||
return c.json(errorBody(err.code, err.message), 409);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /tokens/:id — unregister
|
||||
app.openapi(
|
||||
createRoute({
|
||||
method: "delete",
|
||||
path: "/tokens/{id}",
|
||||
tags: ["push"],
|
||||
summary: "Unregister device token",
|
||||
request: { params: DeviceTokenIdParamSchema },
|
||||
responses: {
|
||||
200: {
|
||||
description: "Unregistered",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: UnregisterDeviceTokenResponseSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "Unauthorized",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description: "Forbidden",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
let user: AuthedUser;
|
||||
try {
|
||||
user = getUser(c);
|
||||
} catch (err) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
(err as NotificationsPluginError).code,
|
||||
(err as Error).message
|
||||
),
|
||||
401
|
||||
);
|
||||
}
|
||||
const { id } = c.req.valid("param");
|
||||
// Look up first by id, then by raw token string. Both
|
||||
// paths return null when not found; the route is idempotent
|
||||
// — deleting a non-existent device is OK.
|
||||
let device = await tokens.findById(id);
|
||||
if (device === null) {
|
||||
device = await tokens.findByToken(id);
|
||||
}
|
||||
if (device === null) {
|
||||
return c.json(
|
||||
{
|
||||
message:
|
||||
"Device unregistered successfully (already absent)",
|
||||
},
|
||||
200
|
||||
);
|
||||
}
|
||||
const ownerId = String(device.user_id).replace(/^user:/, "");
|
||||
const currentUserId = String(user.id).replace(/^user:/, "");
|
||||
if (ownerId !== currentUserId) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
NOTIFICATIONS_PLUGIN_CODES.FORBIDDEN,
|
||||
"Can only unregister your own devices"
|
||||
),
|
||||
403
|
||||
);
|
||||
}
|
||||
await tokens.deactivate(String(device.id));
|
||||
emit?.({
|
||||
type: "notification:device:unregistered",
|
||||
payload: {
|
||||
userId: String(user.id),
|
||||
deviceTokenId: String(device.id),
|
||||
},
|
||||
});
|
||||
return c.json({ message: "Device unregistered successfully" }, 200);
|
||||
}
|
||||
);
|
||||
|
||||
// GET /tokens — list current user's tokens
|
||||
app.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/tokens",
|
||||
tags: ["push"],
|
||||
summary: "List active device tokens",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
platform: z.string(),
|
||||
environment: z.string(),
|
||||
last_used: z.string(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "Unauthorized",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
let user: AuthedUser;
|
||||
try {
|
||||
user = getUser(c);
|
||||
} catch (err) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
(err as NotificationsPluginError).code,
|
||||
(err as Error).message
|
||||
),
|
||||
401
|
||||
);
|
||||
}
|
||||
const list = await tokens.findActiveForUser(user.id);
|
||||
return c.json(
|
||||
{
|
||||
data: list.map((t) => ({
|
||||
id: String(t.id),
|
||||
platform: t.platform,
|
||||
environment: t.environment,
|
||||
last_used: t.last_used,
|
||||
})),
|
||||
},
|
||||
200
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// POST /send — single
|
||||
app.openapi(
|
||||
createRoute({
|
||||
method: "post",
|
||||
path: "/send",
|
||||
tags: ["push"],
|
||||
summary: "Send push notification",
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: SendPushNotificationSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Send result",
|
||||
content: {
|
||||
"application/json": { schema: SendPushResponseSchema },
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "Unauthorized",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
503: {
|
||||
description: "Push unavailable",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
try {
|
||||
getUser(c);
|
||||
} catch (err) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
(err as NotificationsPluginError).code,
|
||||
(err as Error).message
|
||||
),
|
||||
401
|
||||
);
|
||||
}
|
||||
if (apn === null) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
NOTIFICATIONS_PLUGIN_CODES.APN_SERVICE_UNAVAILABLE,
|
||||
"Push notification service not enabled"
|
||||
),
|
||||
503
|
||||
);
|
||||
}
|
||||
const body = c.req.valid("json");
|
||||
const devices = await tokens.findActiveForUser(body.userId);
|
||||
if (devices.length === 0) {
|
||||
return c.json({ success: true, sent: 0, failed: 0 }, 200);
|
||||
}
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
for (const device of devices) {
|
||||
const payload: {
|
||||
deviceToken: string;
|
||||
alert: { title: string; body: string };
|
||||
customData?: Record<string, unknown>;
|
||||
badge?: number;
|
||||
sound?: string;
|
||||
category?: string;
|
||||
} = {
|
||||
deviceToken: device.token,
|
||||
alert: { title: body.title, body: body.body },
|
||||
};
|
||||
if (body.data !== undefined) payload.customData = body.data;
|
||||
if (body.badge !== undefined) payload.badge = body.badge;
|
||||
if (body.sound !== undefined) payload.sound = body.sound;
|
||||
if (body.category !== undefined)
|
||||
payload.category = body.category;
|
||||
const result = await apn.sendNotification(payload);
|
||||
sent += result.sent;
|
||||
failed += result.failed;
|
||||
if (result.sent > 0) {
|
||||
inst.pushSentTotal.add(result.sent, {
|
||||
platform: device.platform,
|
||||
});
|
||||
emit?.({
|
||||
type: "notification:push:sent",
|
||||
payload: {
|
||||
userId: String(body.userId),
|
||||
deviceToken: device.token,
|
||||
platform: device.platform,
|
||||
},
|
||||
});
|
||||
await tokens.touchLastUsed(device.token);
|
||||
}
|
||||
if (result.failed > 0) {
|
||||
const reason = result.errors?.[0]?.reason ?? "unknown";
|
||||
inst.pushFailedTotal.add(result.failed, { reason });
|
||||
emit?.({
|
||||
type: "notification:push:failed",
|
||||
payload: {
|
||||
userId: String(body.userId),
|
||||
deviceToken: device.token,
|
||||
reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return c.json({ success: failed === 0, sent, failed }, 200);
|
||||
}
|
||||
);
|
||||
|
||||
// POST /send-bulk
|
||||
app.openapi(
|
||||
createRoute({
|
||||
method: "post",
|
||||
path: "/send-bulk",
|
||||
tags: ["push"],
|
||||
summary: "Send bulk push notification",
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: SendBulkPushNotificationSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Bulk send result",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: SendBulkPushResponseSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "Unauthorized",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
503: {
|
||||
description: "Push unavailable",
|
||||
content: {
|
||||
"application/json": { schema: ErrorBodySchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
try {
|
||||
getUser(c);
|
||||
} catch (err) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
(err as NotificationsPluginError).code,
|
||||
(err as Error).message
|
||||
),
|
||||
401
|
||||
);
|
||||
}
|
||||
if (apn === null) {
|
||||
return c.json(
|
||||
errorBody(
|
||||
NOTIFICATIONS_PLUGIN_CODES.APN_SERVICE_UNAVAILABLE,
|
||||
"Push notification service not enabled"
|
||||
),
|
||||
503
|
||||
);
|
||||
}
|
||||
const body = c.req.valid("json");
|
||||
const userIds = body.userIds.slice(0, maxBulk);
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
for (const userId of userIds) {
|
||||
const devices = await tokens.findActiveForUser(userId);
|
||||
if (devices.length === 0) continue;
|
||||
const deviceTokens = devices.map((d) => d.token);
|
||||
const bulkPayload: {
|
||||
alert: { title: string; body: string };
|
||||
customData?: Record<string, unknown>;
|
||||
} = {
|
||||
alert: { title: body.title, body: body.body },
|
||||
};
|
||||
if (body.data !== undefined) bulkPayload.customData = body.data;
|
||||
const result = await apn.sendBulkNotifications(
|
||||
deviceTokens,
|
||||
bulkPayload
|
||||
);
|
||||
sent += result.sent;
|
||||
failed += result.failed;
|
||||
if (result.sent > 0) {
|
||||
const platform = (devices[0] as { platform: string })
|
||||
.platform;
|
||||
inst.pushSentTotal.add(result.sent, { platform });
|
||||
}
|
||||
if (result.failed > 0) {
|
||||
const reason = result.errors?.[0]?.reason ?? "unknown";
|
||||
inst.pushFailedTotal.add(result.failed, { reason });
|
||||
}
|
||||
}
|
||||
return c.json(
|
||||
{
|
||||
success: failed === 0,
|
||||
sent,
|
||||
failed,
|
||||
totalUsers: userIds.length,
|
||||
},
|
||||
200
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
# dispatch-schemas
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the Zod boundary for the `NotificationDispatcher` surface. The
|
||||
dispatcher fans a single call out to whichever delivery channel is
|
||||
selected (APN, email, etc.) — and because that call accepts free-form
|
||||
metadata to drive channel-specific behaviours, every field must be
|
||||
validated at the public edge so the dispatcher never sees a malformed
|
||||
payload. The schemas in this module are the contract every caller must
|
||||
satisfy; invalid input folds into
|
||||
`NotificationsPluginError(DISPATCH_INPUT_INVALID)` carrying the
|
||||
`ZodError` as `cause`.
|
||||
|
||||
## Features
|
||||
|
||||
- `DispatchChannelSchema` — closed enum of the six delivery channels
|
||||
the dispatcher knows about (`email`, `sms`, `push`, `in_app`,
|
||||
`webhook`, `apn`). Matches the
|
||||
`notification-channel`-`NotificationChannel` union plus the
|
||||
in-package `apn` literal.
|
||||
- `DispatchPrioritySchema` — `low`/`normal`/`high`/`urgent`.
|
||||
- `DispatchInputSchema` — every public dispatcher input. Requires
|
||||
`recipient` + `body` to be non-empty; everything else is optional
|
||||
with sensible defaults.
|
||||
- `DispatchResultSchema` — shape returned by `dispatch`. `id` and
|
||||
`messageId` carry the same value (the underlying router id), so
|
||||
callers can pass `result.messageId` straight to
|
||||
`DeliveryEventStore` queries.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { DispatchInputSchema } from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
const input = DispatchInputSchema.parse({
|
||||
recipient: "user@example.com",
|
||||
body: "Welcome!",
|
||||
channel: "email",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
DispatchInputSchema,
|
||||
DispatchResultSchema,
|
||||
} from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
// Pre-validate at the route boundary so the dispatcher never sees a
|
||||
// malformed payload.
|
||||
const parsed = DispatchInputSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return c.json({ error: "DISPATCH_INPUT_INVALID" }, 400);
|
||||
}
|
||||
|
||||
const result = await dispatcher.dispatch({
|
||||
...parsed.data,
|
||||
metadata: { deviceTokens: parsed.data.metadata?.deviceTokens ?? [] },
|
||||
idempotencyKey: req.headers.get("idempotency-key") ?? undefined,
|
||||
});
|
||||
|
||||
// `result` is guaranteed by `DispatchResultSchema`.
|
||||
DispatchResultSchema.parse(result);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DispatchChannelSchema`: `ZodEnum<["email","sms","push","in_app","webhook","apn"]>`
|
||||
- `DispatchPrioritySchema`: `ZodEnum<["low","normal","high","urgent"]>`
|
||||
- `DispatchInputSchema`: `ZodObject<{ recipient, body, subject?, channel?, metadata?, priority?, idempotencyKey? }>`
|
||||
- `DispatchResultSchema`: `ZodObject<{ id, messageId, channel, status, sentAt? }>`
|
||||
- Types: `DispatchChannel`, `DispatchPriority`, `DispatchInput`, `DispatchResult`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The schemas live next to the existing notification/push schemas (one
|
||||
`src/schemas/*.ts` per surface) so the public boundary is a single
|
||||
import path.
|
||||
- `metadata` is `Record<string, unknown>` — channels narrow it
|
||||
internally with their own type guards (e.g. the APN channel checks
|
||||
for `deviceToken: string` or `deviceTokens: string[]`).
|
||||
- `DispatchResult.status` is intentionally narrow (`sent`/`delivered`)
|
||||
because the dispatcher rejects failures by throwing
|
||||
`DISPATCH_FAILED`; a `failed` status would be dead data.
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DispatchInputSchema,
|
||||
DispatchResultSchema,
|
||||
} from "./dispatch-schemas.js";
|
||||
|
||||
describe("DispatchInputSchema", () => {
|
||||
it("DispatchInputSchema rejects empty recipient", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = DispatchInputSchema.safeParse({ recipient: "", body: "x" });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("DispatchInputSchema rejects empty body", { timeout: 5000 }, () => {
|
||||
const r = DispatchInputSchema.safeParse({ recipient: "u", body: "" });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("DispatchInputSchema accepts a minimal payload", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = DispatchInputSchema.safeParse({
|
||||
recipient: "u",
|
||||
body: "hi",
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("DispatchInputSchema accepts idempotencyKey", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = DispatchInputSchema.safeParse({
|
||||
recipient: "u",
|
||||
body: "hi",
|
||||
idempotencyKey: "abc",
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown channel literal", { timeout: 5000 }, () => {
|
||||
const r = DispatchInputSchema.safeParse({
|
||||
recipient: "u",
|
||||
body: "hi",
|
||||
channel: "carrier-pigeon",
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown priority literal", { timeout: 5000 }, () => {
|
||||
const r = DispatchInputSchema.safeParse({
|
||||
recipient: "u",
|
||||
body: "hi",
|
||||
priority: "ludicrous",
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty idempotencyKey", { timeout: 5000 }, () => {
|
||||
const r = DispatchInputSchema.safeParse({
|
||||
recipient: "u",
|
||||
body: "hi",
|
||||
idempotencyKey: "",
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DispatchResultSchema", () => {
|
||||
it("DispatchResultSchema accepts canonical shape", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = DispatchResultSchema.safeParse({
|
||||
id: "notif_1",
|
||||
messageId: "notif_1",
|
||||
channel: "apn",
|
||||
status: "sent",
|
||||
sentAt: "2026-05-19T00:00:00.000Z",
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects missing id", { timeout: 5000 }, () => {
|
||||
const r = DispatchResultSchema.safeParse({
|
||||
messageId: "x",
|
||||
channel: "apn",
|
||||
status: "sent",
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Channel literals accepted at the dispatcher boundary. Mirrors the
|
||||
* `NotificationChannel` union from `@suaveplan/notification-channel`
|
||||
* plus the in-package `apn` channel name.
|
||||
*/
|
||||
export const DispatchChannelSchema = z.enum([
|
||||
"email",
|
||||
"sms",
|
||||
"push",
|
||||
"in_app",
|
||||
"webhook",
|
||||
"apn",
|
||||
]);
|
||||
export type DispatchChannel = z.infer<typeof DispatchChannelSchema>;
|
||||
|
||||
/**
|
||||
* Priority literals carried through to the underlying
|
||||
* `NotificationRouter`. Identical wire-shape to upstream's
|
||||
* `NotificationPriority`.
|
||||
*/
|
||||
export const DispatchPrioritySchema = z.enum([
|
||||
"low",
|
||||
"normal",
|
||||
"high",
|
||||
"urgent",
|
||||
]);
|
||||
export type DispatchPriority = z.infer<typeof DispatchPrioritySchema>;
|
||||
|
||||
/**
|
||||
* Boundary schema for a single dispatcher call.
|
||||
*
|
||||
* The dispatcher Zod-validates every public surface before any state
|
||||
* mutates. Invalid input is folded into
|
||||
* `NotificationsPluginError(DISPATCH_INPUT_INVALID)`.
|
||||
*/
|
||||
export const DispatchInputSchema = z.object({
|
||||
/** Logical recipient — depends on channel (email address, phone, deviceToken-bag). */
|
||||
recipient: z.string().min(1, "recipient must be non-empty"),
|
||||
/** Notification body. Channels render this as text by default. */
|
||||
body: z.string().min(1, "body must be non-empty"),
|
||||
/** Optional subject line (email channel + push alert title fallback). */
|
||||
subject: z.string().optional(),
|
||||
/**
|
||||
* Explicit channel selection. When omitted, the dispatcher falls
|
||||
* back to its configured `defaultChannel`.
|
||||
*/
|
||||
channel: DispatchChannelSchema.optional(),
|
||||
/**
|
||||
* Channel-specific metadata. The APN channel reads `deviceToken`
|
||||
* or `deviceTokens` from here; the email channel reads `html`.
|
||||
*/
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
/** Priority hint forwarded to the router. */
|
||||
priority: DispatchPrioritySchema.optional(),
|
||||
/**
|
||||
* Idempotency key. When present AND a non-noop adapter is
|
||||
* configured, the dispatcher routes the call through
|
||||
* `adapter.execute(key, …)` to dedup duplicates.
|
||||
*/
|
||||
idempotencyKey: z.string().min(1).optional(),
|
||||
});
|
||||
export type DispatchInput = z.infer<typeof DispatchInputSchema>;
|
||||
|
||||
/**
|
||||
* Shape returned by `NotificationDispatcher.dispatch`. The id is
|
||||
* surfaced from the underlying router (`notif_<n>`); `messageId`
|
||||
* carries the same value to align with the `DeliveryEvent.messageId`
|
||||
* field consumed by `@suaveplan/delivery-tracking`.
|
||||
*/
|
||||
export const DispatchResultSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
messageId: z.string().min(1),
|
||||
channel: DispatchChannelSchema,
|
||||
status: z.enum(["sent", "delivered"]),
|
||||
sentAt: z.string().optional(),
|
||||
});
|
||||
export type DispatchResult = z.infer<typeof DispatchResultSchema>;
|
||||
@@ -0,0 +1,59 @@
|
||||
export {
|
||||
type BulkOperationResponse,
|
||||
BulkOperationResponseSchema,
|
||||
type CreateNotification,
|
||||
CreateNotificationSchema,
|
||||
type GroupedNotifications,
|
||||
GroupedNotificationsSchema,
|
||||
type MarkManyAsRead,
|
||||
MarkManyAsReadSchema,
|
||||
type Notification,
|
||||
type NotificationIdParam,
|
||||
NotificationIdParamSchema,
|
||||
type NotificationListResponse,
|
||||
NotificationListResponseSchema,
|
||||
type NotificationMetadata,
|
||||
NotificationMetadataSchema,
|
||||
type NotificationQuery,
|
||||
NotificationQuerySchema,
|
||||
NotificationSchema,
|
||||
type NotificationType,
|
||||
NotificationTypeSchema,
|
||||
type TargetType,
|
||||
TargetTypeSchema,
|
||||
type UnreadCountResponse,
|
||||
UnreadCountResponseSchema,
|
||||
type UpdateNotification,
|
||||
UpdateNotificationSchema,
|
||||
} from "./notification-schemas.js";
|
||||
export {
|
||||
type ApnConfig,
|
||||
ApnConfigSchema,
|
||||
type NotificationsPluginConfig,
|
||||
type NotificationsPluginConfigInput,
|
||||
NotificationsPluginOptionsSchema,
|
||||
} from "./options.js";
|
||||
export {
|
||||
type DeviceEnvironment,
|
||||
DeviceEnvironmentSchema,
|
||||
type DevicePlatform,
|
||||
DevicePlatformSchema,
|
||||
type DeviceToken,
|
||||
type DeviceTokenIdParam,
|
||||
DeviceTokenIdParamSchema,
|
||||
DeviceTokenSchema,
|
||||
type RegisterDeviceToken,
|
||||
type RegisterDeviceTokenResponse,
|
||||
RegisterDeviceTokenResponseSchema,
|
||||
RegisterDeviceTokenSchema,
|
||||
type SendBulkPushNotification,
|
||||
SendBulkPushNotificationSchema,
|
||||
type SendBulkPushResponse,
|
||||
SendBulkPushResponseSchema,
|
||||
type SendPushNotification,
|
||||
SendPushNotificationSchema,
|
||||
type SendPushResponse,
|
||||
SendPushResponseSchema,
|
||||
type UnregisterDeviceTokenResponse,
|
||||
UnregisterDeviceTokenResponseSchema,
|
||||
} from "./push-schemas.js";
|
||||
@@ -0,0 +1,95 @@
|
||||
# `schemas/notification-schemas`
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod boundary schemas for the notification entity and every wire surface
|
||||
the plugin exposes — list / get / create / update / mark-read /
|
||||
mark-many-as-read / dismiss / delete / unread-count / grouped. The
|
||||
schemas live inside the plugin because the suaveplan
|
||||
`@suaveplan/api-schemas` package does not yet ship a `notification`
|
||||
subpath; co-locating keeps the plugin self-contained until the upstream
|
||||
package is augmented.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod 4 default-import style (`import z from "zod"`) per CLAUDE.md.
|
||||
- Twelve canonical notification types and six target types, both
|
||||
pinned by `z.enum(...)`.
|
||||
- Passthrough `NotificationMetadataSchema` so callers may attach
|
||||
domain-specific fields without forking the schema.
|
||||
- Query schema with `z.coerce.number()` / `z.coerce.boolean()` so
|
||||
query-string values arriving as strings parse cleanly.
|
||||
- Response wrappers (`NotificationListResponseSchema`,
|
||||
`UnreadCountResponseSchema`, `GroupedNotificationsSchema`,
|
||||
`BulkOperationResponseSchema`) used by `createNotificationRoutes`.
|
||||
- Param schema for `/:id` path captures (`NotificationIdParamSchema`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CreateNotificationSchema } from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
const data = CreateNotificationSchema.parse({
|
||||
type: "friend_request",
|
||||
recipient: "user:abc",
|
||||
content: "sent you a friend request",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Compose with `@hono/zod-openapi`'s `createRoute` to produce an OpenAPI
|
||||
schema and request validator in one step:
|
||||
|
||||
```ts
|
||||
import { createRoute } from "@hono/zod-openapi";
|
||||
import {
|
||||
CreateNotificationSchema,
|
||||
NotificationSchema,
|
||||
} from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
createRoute({
|
||||
method: "post",
|
||||
path: "/",
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": { schema: CreateNotificationSchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: "Created",
|
||||
content: {
|
||||
"application/json": { schema: NotificationSchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NotificationTypeSchema` / `NotificationType`
|
||||
- `TargetTypeSchema` / `TargetType`
|
||||
- `NotificationMetadataSchema` / `NotificationMetadata`
|
||||
- `NotificationSchema` / `Notification`
|
||||
- `CreateNotificationSchema` / `CreateNotification`
|
||||
- `UpdateNotificationSchema` / `UpdateNotification`
|
||||
- `NotificationQuerySchema` / `NotificationQuery`
|
||||
- `NotificationListResponseSchema` / `NotificationListResponse`
|
||||
- `UnreadCountResponseSchema` / `UnreadCountResponse`
|
||||
- `GroupedNotificationsSchema` / `GroupedNotifications`
|
||||
- `MarkManyAsReadSchema` / `MarkManyAsRead`
|
||||
- `NotificationIdParamSchema` / `NotificationIdParam`
|
||||
- `BulkOperationResponseSchema` / `BulkOperationResponse`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`recipient`, `actor`, `target_id`, and `site` are typed as `z.string()`
|
||||
rather than tightening to a SurrealDB record-id regex because tests and
|
||||
upstream callers pass both bare ids (`abc`) and prefixed ids
|
||||
(`user:abc`); the repository normalises on the way down. The schema
|
||||
deliberately accepts both forms at the boundary so handlers can stay
|
||||
permissive while persistence remains strict.
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
CreateNotificationSchema,
|
||||
MarkManyAsReadSchema,
|
||||
NotificationIdParamSchema,
|
||||
NotificationListResponseSchema,
|
||||
NotificationQuerySchema,
|
||||
NotificationSchema,
|
||||
NotificationTypeSchema,
|
||||
TargetTypeSchema,
|
||||
UpdateNotificationSchema,
|
||||
} from "./notification-schemas.js";
|
||||
|
||||
describe("NotificationTypeSchema", () => {
|
||||
it("accepts canonical types", { timeout: 5000 }, () => {
|
||||
expect(NotificationTypeSchema.parse("friend_request")).toBe(
|
||||
"friend_request"
|
||||
);
|
||||
expect(NotificationTypeSchema.parse("message")).toBe("message");
|
||||
});
|
||||
it("rejects unknown types", { timeout: 5000 }, () => {
|
||||
expect(() => NotificationTypeSchema.parse("bogus")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TargetTypeSchema", () => {
|
||||
it("accepts canonical targets", { timeout: 5000 }, () => {
|
||||
expect(TargetTypeSchema.parse("post")).toBe("post");
|
||||
});
|
||||
it("rejects unknown targets", { timeout: 5000 }, () => {
|
||||
expect(() => TargetTypeSchema.parse("planet")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateNotificationSchema", () => {
|
||||
it("requires type, recipient, content", { timeout: 5000 }, () => {
|
||||
const ok = CreateNotificationSchema.parse({
|
||||
type: "friend_request",
|
||||
recipient: "user:1",
|
||||
content: "hi",
|
||||
});
|
||||
expect(ok.read).toBe(false);
|
||||
expect(ok.dismissed).toBe(false);
|
||||
});
|
||||
it("rejects missing content", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
CreateNotificationSchema.parse({
|
||||
type: "friend_request",
|
||||
recipient: "user:1",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
it("accepts metadata passthrough", { timeout: 5000 }, () => {
|
||||
const v = CreateNotificationSchema.parse({
|
||||
type: "post_like",
|
||||
recipient: "user:1",
|
||||
content: "liked",
|
||||
metadata: { title: "T", custom_field: "X" },
|
||||
});
|
||||
expect((v.metadata as Record<string, unknown>).custom_field).toBe("X");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpdateNotificationSchema", () => {
|
||||
it("accepts a partial body", { timeout: 5000 }, () => {
|
||||
const v = UpdateNotificationSchema.parse({ read: true });
|
||||
expect(v.read).toBe(true);
|
||||
});
|
||||
it("accepts an empty body", { timeout: 5000 }, () => {
|
||||
const v = UpdateNotificationSchema.parse({});
|
||||
expect(v).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationQuerySchema", () => {
|
||||
it("coerces string query params", { timeout: 5000 }, () => {
|
||||
const v = NotificationQuerySchema.parse({
|
||||
limit: "30",
|
||||
offset: "5",
|
||||
unreadOnly: "true",
|
||||
});
|
||||
expect(v.limit).toBe(30);
|
||||
expect(v.offset).toBe(5);
|
||||
expect(v.unreadOnly).toBe(true);
|
||||
});
|
||||
it("rejects limit > 100", { timeout: 5000 }, () => {
|
||||
expect(() => NotificationQuerySchema.parse({ limit: "500" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationListResponseSchema", () => {
|
||||
it("validates a response envelope", { timeout: 5000 }, () => {
|
||||
const v = NotificationListResponseSchema.parse({
|
||||
data: [],
|
||||
total: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
expect(v.total).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MarkManyAsReadSchema", () => {
|
||||
it("requires at least one id", { timeout: 5000 }, () => {
|
||||
expect(() => MarkManyAsReadSchema.parse({ ids: [] })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationIdParamSchema", () => {
|
||||
it("rejects empty id", { timeout: 5000 }, () => {
|
||||
expect(() => NotificationIdParamSchema.parse({ id: "" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationSchema", () => {
|
||||
it("parses a complete row", { timeout: 5000 }, () => {
|
||||
const v = NotificationSchema.parse({
|
||||
id: "notification:1",
|
||||
type: "friend_request",
|
||||
recipient: "user:1",
|
||||
content: "hi",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
expect(v.read).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
// schema: boundary-only
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Notification entity / wire schemas owned locally by the plugin.
|
||||
*
|
||||
* `@suaveplan/api-schemas` on genesis does not yet ship a `notification`
|
||||
* subpath, so the plugin owns the boundary schemas the same way
|
||||
* `@suaveplan/api-plugin-events` owns its event-entity schemas. See
|
||||
* `openspec/changes/add-api-plugin-notifications/proposal.md` for the
|
||||
* rationale and the eventual upstream-merge path.
|
||||
*/
|
||||
|
||||
export const NotificationTypeSchema = z.enum([
|
||||
"friend_request",
|
||||
"friend_accepted",
|
||||
"post_like",
|
||||
"post_comment",
|
||||
"post_mention",
|
||||
"group_invite",
|
||||
"group_join",
|
||||
"group_mention",
|
||||
"system",
|
||||
"achievement",
|
||||
"follow",
|
||||
"message",
|
||||
]);
|
||||
export type NotificationType = z.infer<typeof NotificationTypeSchema>;
|
||||
|
||||
export const TargetTypeSchema = z.enum([
|
||||
"user",
|
||||
"post",
|
||||
"comment",
|
||||
"group",
|
||||
"achievement",
|
||||
"system",
|
||||
]);
|
||||
export type TargetType = z.infer<typeof TargetTypeSchema>;
|
||||
|
||||
export const NotificationMetadataSchema = z
|
||||
.object({
|
||||
title: z.string().optional(),
|
||||
body: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
action_type: z.number().optional(),
|
||||
action_target: z.string().optional(),
|
||||
imageUrl: z.string().url().optional(),
|
||||
url: z.string().url().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
export type NotificationMetadata = z.infer<typeof NotificationMetadataSchema>;
|
||||
|
||||
export const NotificationSchema = z.object({
|
||||
id: z.string(),
|
||||
type: NotificationTypeSchema,
|
||||
recipient: z.string().describe("User receiving the notification"),
|
||||
actor: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("User who triggered the notification"),
|
||||
target_id: z.string().optional(),
|
||||
target_type: TargetTypeSchema.optional(),
|
||||
site: z.string().optional(),
|
||||
content: z.string(),
|
||||
metadata: NotificationMetadataSchema.optional(),
|
||||
read: z.boolean().default(false),
|
||||
dismissed: z.boolean().default(false),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
});
|
||||
export type Notification = z.infer<typeof NotificationSchema>;
|
||||
|
||||
export const CreateNotificationSchema = z.object({
|
||||
type: NotificationTypeSchema,
|
||||
recipient: z.string(),
|
||||
actor: z.string().optional(),
|
||||
target_id: z.string().optional(),
|
||||
target_type: TargetTypeSchema.optional(),
|
||||
site: z.string().optional(),
|
||||
content: z.string().min(1),
|
||||
metadata: NotificationMetadataSchema.optional(),
|
||||
read: z.boolean().optional().default(false),
|
||||
dismissed: z.boolean().optional().default(false),
|
||||
});
|
||||
export type CreateNotification = z.infer<typeof CreateNotificationSchema>;
|
||||
|
||||
export const UpdateNotificationSchema = z.object({
|
||||
read: z.boolean().optional(),
|
||||
dismissed: z.boolean().optional(),
|
||||
metadata: NotificationMetadataSchema.optional(),
|
||||
});
|
||||
export type UpdateNotification = z.infer<typeof UpdateNotificationSchema>;
|
||||
|
||||
export const NotificationQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
unreadOnly: z.coerce.boolean().optional().default(false),
|
||||
type: NotificationTypeSchema.optional(),
|
||||
since: z.string().datetime().optional(),
|
||||
});
|
||||
export type NotificationQuery = z.infer<typeof NotificationQuerySchema>;
|
||||
|
||||
export const NotificationListResponseSchema = z.object({
|
||||
data: z.array(NotificationSchema),
|
||||
total: z.number().int(),
|
||||
hasMore: z.boolean().optional(),
|
||||
});
|
||||
export type NotificationListResponse = z.infer<
|
||||
typeof NotificationListResponseSchema
|
||||
>;
|
||||
|
||||
export const UnreadCountResponseSchema = z.object({
|
||||
count: z.number().int().min(0),
|
||||
});
|
||||
export type UnreadCountResponse = z.infer<typeof UnreadCountResponseSchema>;
|
||||
|
||||
export const GroupedNotificationsSchema = z.record(
|
||||
z.string(),
|
||||
z.array(NotificationSchema)
|
||||
);
|
||||
export type GroupedNotifications = z.infer<typeof GroupedNotificationsSchema>;
|
||||
|
||||
export const MarkManyAsReadSchema = z.object({
|
||||
ids: z.array(z.string()).min(1),
|
||||
});
|
||||
export type MarkManyAsRead = z.infer<typeof MarkManyAsReadSchema>;
|
||||
|
||||
export const NotificationIdParamSchema = z.object({ id: z.string().min(1) });
|
||||
export type NotificationIdParam = z.infer<typeof NotificationIdParamSchema>;
|
||||
|
||||
export const BulkOperationResponseSchema = z.object({
|
||||
updated: z.number().int().min(0),
|
||||
});
|
||||
export type BulkOperationResponse = z.infer<typeof BulkOperationResponseSchema>;
|
||||
@@ -0,0 +1,73 @@
|
||||
# `schemas/options`
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schema for plugin options accepted by `createNotificationsPlugin()`
|
||||
plus the nested APN configuration shape (`ApnConfigSchema`). Every
|
||||
field carries a default so consumers can call the factory with no
|
||||
arguments and still receive a fully-resolved
|
||||
`NotificationsPluginConfig`. Invalid input surfaces a single
|
||||
`NotificationsPluginError` with `code === INVALID_CONFIG` and the
|
||||
`ZodError` as `cause`.
|
||||
|
||||
## Features
|
||||
|
||||
- `basePath` defaults to `/notifications`; `pushBasePath` defaults to
|
||||
`/push` — both mount independently so hosts can split the surface
|
||||
behind different auth gates.
|
||||
- `enablePush` flag gates the APN service registration. When the flag
|
||||
is `false` the plugin still mounts `/notifications` but the
|
||||
`/push/*` routes return `503` for delivery endpoints.
|
||||
- `apn` accepts either certificate / key / pfx pairs or a token
|
||||
bundle (`{ key, keyId, teamId }`). Boundary validation happens here;
|
||||
cross-field validation (exactly-one-auth-method) happens later in
|
||||
the APN service.
|
||||
- `autoCleanOldDays` controls the optional retention job (`0` disables
|
||||
cleanup); `maxBulkRecipients` caps the size of `POST /push/send-bulk`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createNotificationsPlugin } from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
const plugin = createNotificationsPlugin({
|
||||
basePath: "/v1/notifications",
|
||||
enablePush: true,
|
||||
apn: {
|
||||
token: { key: "/keys/AuthKey_ABC.p8", keyId: "ABC", teamId: "TEAM" },
|
||||
defaultTopic: "com.example.app",
|
||||
production: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
`z.input<typeof NotificationsPluginOptionsSchema>` is exported as
|
||||
`NotificationsPluginConfigInput` so callers can use it to type their
|
||||
own deployment-config files:
|
||||
|
||||
```ts
|
||||
import type { NotificationsPluginConfigInput } from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
interface DeploymentConfig {
|
||||
plugins: { notifications: NotificationsPluginConfigInput };
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ApnConfigSchema` / `ApnConfig`
|
||||
- `NotificationsPluginOptionsSchema`
|
||||
- `NotificationsPluginConfig` — `z.infer<...>`
|
||||
- `NotificationsPluginConfigInput` — `z.input<...>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema does not enforce "exactly one of cert/key/pfx/token" at the
|
||||
boundary because some deployments pass `passphrase` alone (when the
|
||||
key file already lives on disk and is referenced by path). The APN
|
||||
service's `validateConfig()` method enforces the cross-field rule and
|
||||
throws `APN_CONFIG_MISSING` / `APN_CONFIG_INVALID` at
|
||||
`initialize()` time so the error surfaces with a stable code and full
|
||||
telemetry context.
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
ApnConfigSchema,
|
||||
NotificationsPluginOptionsSchema,
|
||||
} from "./options.js";
|
||||
|
||||
describe("NotificationsPluginOptionsSchema", () => {
|
||||
it("applies defaults on empty input", { timeout: 5000 }, () => {
|
||||
const v = NotificationsPluginOptionsSchema.parse({});
|
||||
expect(v.basePath).toBe("/notifications");
|
||||
expect(v.pushBasePath).toBe("/push");
|
||||
expect(v.enablePush).toBe(false);
|
||||
expect(v.autoCleanOldDays).toBe(30);
|
||||
expect(v.maxBulkRecipients).toBe(1000);
|
||||
expect(v.enableDispatcher).toBe(false);
|
||||
expect(v.defaultChannel).toBe("apn");
|
||||
expect(v.deliveryTenantId).toBe("default");
|
||||
});
|
||||
|
||||
it("accepts enableDispatcher + defaultChannel + tenantId", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const v = NotificationsPluginOptionsSchema.parse({
|
||||
enableDispatcher: true,
|
||||
defaultChannel: "email",
|
||||
deliveryTenantId: "tenant-a",
|
||||
});
|
||||
expect(v.enableDispatcher).toBe(true);
|
||||
expect(v.defaultChannel).toBe("email");
|
||||
expect(v.deliveryTenantId).toBe("tenant-a");
|
||||
});
|
||||
|
||||
it("rejects unknown defaultChannel literal", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
NotificationsPluginOptionsSchema.parse({
|
||||
defaultChannel: "carrier-pigeon",
|
||||
} as never)
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty deliveryTenantId", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
NotificationsPluginOptionsSchema.parse({ deliveryTenantId: "" })
|
||||
).toThrow();
|
||||
});
|
||||
it("rejects empty basePath", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
NotificationsPluginOptionsSchema.parse({ basePath: "" })
|
||||
).toThrow();
|
||||
});
|
||||
it("rejects negative autoCleanOldDays", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
NotificationsPluginOptionsSchema.parse({ autoCleanOldDays: -1 })
|
||||
).toThrow();
|
||||
});
|
||||
it("rejects maxBulkRecipients > 10000", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
NotificationsPluginOptionsSchema.parse({
|
||||
maxBulkRecipients: 20000,
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
it("accepts a valid APN config", { timeout: 5000 }, () => {
|
||||
const v = NotificationsPluginOptionsSchema.parse({
|
||||
enablePush: true,
|
||||
apn: {
|
||||
token: { key: "k", keyId: "i", teamId: "t" },
|
||||
production: true,
|
||||
},
|
||||
});
|
||||
expect(v.apn?.production).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApnConfigSchema", () => {
|
||||
it("defaults production to false", { timeout: 5000 }, () => {
|
||||
const v = ApnConfigSchema.parse({});
|
||||
expect(v.production).toBe(false);
|
||||
});
|
||||
it("rejects partial token (missing teamId)", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
ApnConfigSchema.parse({
|
||||
token: { key: "k", keyId: "i", teamId: "" },
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import z from "zod";
|
||||
|
||||
/** Channel literals from `@suaveplan/notification-channel`, mirrored here so Zod can validate. */
|
||||
const DispatcherChannelEnum = z.enum([
|
||||
"email",
|
||||
"sms",
|
||||
"push",
|
||||
"in_app",
|
||||
"webhook",
|
||||
"apn",
|
||||
]);
|
||||
|
||||
/**
|
||||
* APN configuration accepted at plugin-construction time. Mirrors the
|
||||
* upstream `APNConfig` surface but is owned by the plugin's Zod
|
||||
* boundary so the values arrive at the transport already validated.
|
||||
*/
|
||||
export const ApnConfigSchema = z.object({
|
||||
cert: z.string().optional(),
|
||||
key: z.string().optional(),
|
||||
pfx: z.string().optional(),
|
||||
passphrase: z.string().optional(),
|
||||
token: z
|
||||
.object({
|
||||
key: z.string().min(1),
|
||||
keyId: z.string().min(1),
|
||||
teamId: z.string().min(1),
|
||||
})
|
||||
.optional(),
|
||||
production: z.boolean().default(false),
|
||||
defaultTopic: z.string().optional(),
|
||||
address: z.string().optional(),
|
||||
port: z.number().int().positive().optional(),
|
||||
connectionRetryLimit: z.number().int().min(0).optional(),
|
||||
requestTimeout: z.number().int().positive().optional(),
|
||||
rejectUnauthorized: z.boolean().optional(),
|
||||
});
|
||||
export type ApnConfig = z.infer<typeof ApnConfigSchema>;
|
||||
|
||||
/**
|
||||
* Plugin options accepted by `createNotificationsPlugin`.
|
||||
*
|
||||
* Every field has a default so a no-arg call
|
||||
* (`createNotificationsPlugin()`) produces a fully-resolved
|
||||
* `NotificationsPluginConfig`.
|
||||
*/
|
||||
export const NotificationsPluginOptionsSchema = z.object({
|
||||
/** Mount prefix for the `/notifications` route group. */
|
||||
basePath: z.string().min(1).default("/notifications"),
|
||||
/** Mount prefix for the `/push` route group. */
|
||||
pushBasePath: z.string().min(1).default("/push"),
|
||||
/** Enable APN push delivery (registers the APN service). */
|
||||
enablePush: z.boolean().default(false),
|
||||
/** APN-specific config; required only when `enablePush === true`. */
|
||||
apn: ApnConfigSchema.optional(),
|
||||
/** Auto-clean read notifications older than N days (0 disables). */
|
||||
autoCleanOldDays: z.number().int().min(0).default(30),
|
||||
/** Maximum recipients accepted by `POST /push/send-bulk`. */
|
||||
maxBulkRecipients: z.number().int().min(1).max(10000).default(1000),
|
||||
/**
|
||||
* Opt in to constructing a `NotificationDispatcher` that fans
|
||||
* notifications across `notification-channel`-style delivery
|
||||
* channels. Off by default — existing routes still call
|
||||
* `apnService` directly.
|
||||
*/
|
||||
enableDispatcher: z.boolean().default(false),
|
||||
/**
|
||||
* Default delivery channel used by the dispatcher when an input
|
||||
* does not specify one. Only consulted when `enableDispatcher` is
|
||||
* on.
|
||||
*/
|
||||
defaultChannel: DispatcherChannelEnum.default("apn"),
|
||||
/**
|
||||
* Tenant id stamped on every `DeliveryEvent` emitted by the
|
||||
* dispatcher. Required to make the events queryable per-tenant.
|
||||
*/
|
||||
deliveryTenantId: z.string().min(1).default("default"),
|
||||
});
|
||||
|
||||
/** Resolved config used by the plugin runtime. */
|
||||
export type NotificationsPluginConfig = z.infer<
|
||||
typeof NotificationsPluginOptionsSchema
|
||||
>;
|
||||
|
||||
/** Caller-facing config — every field optional. */
|
||||
export type NotificationsPluginConfigInput = z.input<
|
||||
typeof NotificationsPluginOptionsSchema
|
||||
>;
|
||||
@@ -0,0 +1,97 @@
|
||||
# `schemas/push-schemas`
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod boundary schemas for the `/push/*` route group and the
|
||||
device-token entity stored in SurrealDB. Schemas describe both the wire
|
||||
surface (register / unregister / send / send-bulk requests and
|
||||
responses) and the persisted record shape that
|
||||
`DeviceTokenRepository` reads and writes.
|
||||
|
||||
## Features
|
||||
|
||||
- Three constrained enums (`DevicePlatformSchema`,
|
||||
`DeviceEnvironmentSchema`) so platform and environment can only be
|
||||
one of the documented literals.
|
||||
- Defaults on optional fields (`deviceType` defaults to `"ios"`,
|
||||
`environment` defaults to `"sandbox"`) so callers can pass minimal
|
||||
payloads.
|
||||
- Hard bounds on payload sizes (`title` ≤ 200, `body` ≤ 2000,
|
||||
`userIds.length` ≤ 1000) so the routes reject pathological input at
|
||||
the boundary before any database work happens.
|
||||
- Response envelopes that match the route handler's serialisation:
|
||||
every push response carries `success`, `sent`, `failed`, and (for
|
||||
the bulk variant) `totalUsers`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
RegisterDeviceTokenSchema,
|
||||
SendPushNotificationSchema,
|
||||
} from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
const reg = RegisterDeviceTokenSchema.parse({ deviceToken: "abc123" });
|
||||
const send = SendPushNotificationSchema.parse({
|
||||
userId: "user:42",
|
||||
title: "Hi",
|
||||
body: "Welcome",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Compose with `@hono/zod-openapi` `createRoute` to generate OpenAPI
|
||||
operation schemas and validators for the `/push/*` group:
|
||||
|
||||
```ts
|
||||
import { createRoute } from "@hono/zod-openapi";
|
||||
import {
|
||||
SendBulkPushNotificationSchema,
|
||||
SendBulkPushResponseSchema,
|
||||
} from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
createRoute({
|
||||
method: "post",
|
||||
path: "/send-bulk",
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: SendBulkPushNotificationSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Bulk send result",
|
||||
content: {
|
||||
"application/json": { schema: SendBulkPushResponseSchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DevicePlatformSchema` / `DevicePlatform`
|
||||
- `DeviceEnvironmentSchema` / `DeviceEnvironment`
|
||||
- `DeviceTokenSchema` / `DeviceToken`
|
||||
- `RegisterDeviceTokenSchema` / `RegisterDeviceToken`
|
||||
- `RegisterDeviceTokenResponseSchema` / `RegisterDeviceTokenResponse`
|
||||
- `UnregisterDeviceTokenResponseSchema` / `UnregisterDeviceTokenResponse`
|
||||
- `SendPushNotificationSchema` / `SendPushNotification`
|
||||
- `SendBulkPushNotificationSchema` / `SendBulkPushNotification`
|
||||
- `SendPushResponseSchema` / `SendPushResponse`
|
||||
- `SendBulkPushResponseSchema` / `SendBulkPushResponse`
|
||||
- `DeviceTokenIdParamSchema` / `DeviceTokenIdParam`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The wire-side `deviceToken` field is named with the leading lower-camel
|
||||
prefix even though the database column is `token`; the route handler
|
||||
maps between the two. This matches the upstream public surface and
|
||||
keeps mobile-client payloads readable without forcing every iOS / FCM
|
||||
client to rename its outbound key.
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DeviceTokenIdParamSchema,
|
||||
DeviceTokenSchema,
|
||||
RegisterDeviceTokenSchema,
|
||||
SendBulkPushNotificationSchema,
|
||||
SendPushNotificationSchema,
|
||||
} from "./push-schemas.js";
|
||||
|
||||
describe("RegisterDeviceTokenSchema", () => {
|
||||
it("applies default deviceType / environment", { timeout: 5000 }, () => {
|
||||
const v = RegisterDeviceTokenSchema.parse({ deviceToken: "abc" });
|
||||
expect(v.deviceType).toBe("ios");
|
||||
expect(v.environment).toBe("sandbox");
|
||||
});
|
||||
it("rejects empty deviceToken", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
RegisterDeviceTokenSchema.parse({ deviceToken: "" })
|
||||
).toThrow();
|
||||
});
|
||||
it("rejects too-long deviceToken", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
RegisterDeviceTokenSchema.parse({
|
||||
deviceToken: "x".repeat(1001),
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
it("rejects invalid deviceType", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
RegisterDeviceTokenSchema.parse({
|
||||
deviceToken: "abc",
|
||||
deviceType: "windows" as never,
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SendPushNotificationSchema", () => {
|
||||
it("requires userId/title/body", { timeout: 5000 }, () => {
|
||||
const v = SendPushNotificationSchema.parse({
|
||||
userId: "user:1",
|
||||
title: "T",
|
||||
body: "B",
|
||||
});
|
||||
expect(v.userId).toBe("user:1");
|
||||
});
|
||||
it("rejects empty title", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
SendPushNotificationSchema.parse({
|
||||
userId: "user:1",
|
||||
title: "",
|
||||
body: "B",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SendBulkPushNotificationSchema", () => {
|
||||
it("caps userIds at 1000", { timeout: 5000 }, () => {
|
||||
const ids = Array.from({ length: 1001 }, (_, i) => `user:${i}`);
|
||||
expect(() =>
|
||||
SendBulkPushNotificationSchema.parse({
|
||||
userIds: ids,
|
||||
title: "T",
|
||||
body: "B",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
it("requires at least one userId", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
SendBulkPushNotificationSchema.parse({
|
||||
userIds: [],
|
||||
title: "T",
|
||||
body: "B",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DeviceTokenSchema", () => {
|
||||
it("parses a fully-populated row", { timeout: 5000 }, () => {
|
||||
const v = DeviceTokenSchema.parse({
|
||||
id: "device_token:1",
|
||||
user_id: "user:1",
|
||||
device_id: "dev1",
|
||||
token: "abc",
|
||||
platform: "ios",
|
||||
environment: "sandbox",
|
||||
is_active: true,
|
||||
last_used: "2026-01-01T00:00:00Z",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
expect(v.platform).toBe("ios");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DeviceTokenIdParamSchema", () => {
|
||||
it("rejects empty id", { timeout: 5000 }, () => {
|
||||
expect(() => DeviceTokenIdParamSchema.parse({ id: "" })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
// schema: boundary-only
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod boundary schemas for the `/push/*` route group and the APN
|
||||
* transport contract.
|
||||
*/
|
||||
|
||||
export const DevicePlatformSchema = z.enum(["ios", "android", "web"]);
|
||||
export type DevicePlatform = z.infer<typeof DevicePlatformSchema>;
|
||||
|
||||
export const DeviceEnvironmentSchema = z.enum([
|
||||
"production",
|
||||
"sandbox",
|
||||
"development",
|
||||
]);
|
||||
export type DeviceEnvironment = z.infer<typeof DeviceEnvironmentSchema>;
|
||||
|
||||
export const DeviceTokenSchema = z.object({
|
||||
id: z.string(),
|
||||
user_id: z.string(),
|
||||
site: z.string().optional(),
|
||||
device_id: z.string(),
|
||||
token: z.string(),
|
||||
platform: DevicePlatformSchema,
|
||||
environment: DeviceEnvironmentSchema,
|
||||
app_version: z.string().optional(),
|
||||
os_version: z.string().optional(),
|
||||
is_active: z.boolean(),
|
||||
last_used: z.string(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
});
|
||||
export type DeviceToken = z.infer<typeof DeviceTokenSchema>;
|
||||
|
||||
export const RegisterDeviceTokenSchema = z.object({
|
||||
deviceToken: z.string().min(1).max(1000),
|
||||
deviceId: z.string().min(1).max(255).optional(),
|
||||
deviceType: DevicePlatformSchema.optional().default("ios"),
|
||||
environment: DeviceEnvironmentSchema.optional().default("sandbox"),
|
||||
appVersion: z.string().optional(),
|
||||
osVersion: z.string().optional(),
|
||||
});
|
||||
export type RegisterDeviceToken = z.infer<typeof RegisterDeviceTokenSchema>;
|
||||
|
||||
export const RegisterDeviceTokenResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
deviceTokenId: z.string(),
|
||||
});
|
||||
export type RegisterDeviceTokenResponse = z.infer<
|
||||
typeof RegisterDeviceTokenResponseSchema
|
||||
>;
|
||||
|
||||
export const UnregisterDeviceTokenResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
});
|
||||
export type UnregisterDeviceTokenResponse = z.infer<
|
||||
typeof UnregisterDeviceTokenResponseSchema
|
||||
>;
|
||||
|
||||
export const SendPushNotificationSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
title: z.string().min(1).max(200),
|
||||
body: z.string().min(1).max(2000),
|
||||
data: z.record(z.string(), z.unknown()).optional(),
|
||||
badge: z.number().int().min(0).optional(),
|
||||
sound: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
});
|
||||
export type SendPushNotification = z.infer<typeof SendPushNotificationSchema>;
|
||||
|
||||
export const SendBulkPushNotificationSchema = z.object({
|
||||
userIds: z.array(z.string().min(1)).min(1).max(1000),
|
||||
title: z.string().min(1).max(200),
|
||||
body: z.string().min(1).max(2000),
|
||||
data: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
export type SendBulkPushNotification = z.infer<
|
||||
typeof SendBulkPushNotificationSchema
|
||||
>;
|
||||
|
||||
export const SendPushResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
sent: z.number().int().min(0),
|
||||
failed: z.number().int().min(0),
|
||||
});
|
||||
export type SendPushResponse = z.infer<typeof SendPushResponseSchema>;
|
||||
|
||||
export const SendBulkPushResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
sent: z.number().int().min(0),
|
||||
failed: z.number().int().min(0),
|
||||
totalUsers: z.number().int().min(0),
|
||||
});
|
||||
export type SendBulkPushResponse = z.infer<typeof SendBulkPushResponseSchema>;
|
||||
|
||||
export const DeviceTokenIdParamSchema = z.object({ id: z.string().min(1) });
|
||||
export type DeviceTokenIdParam = z.infer<typeof DeviceTokenIdParamSchema>;
|
||||
@@ -0,0 +1,105 @@
|
||||
# `services/apn-service`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Apple Push Notification delivery service. Validates an `ApnConfig` at
|
||||
construction-time, builds a pluggable `ApnTransport` (defaulting to
|
||||
the deterministic `MemoryApnTransport`), translates the public
|
||||
`PushNotificationPayload` shape into the transport's library-agnostic
|
||||
notification format, and maps the transport's response into a
|
||||
`PushNotificationResult` envelope. Per-device delivery failures NEVER
|
||||
throw — they land in the result's `errors[]` array so callers can
|
||||
branch on them without try/catch noise.
|
||||
|
||||
## Features
|
||||
|
||||
- Three stable failure modes: `APN_CONFIG_MISSING` (no auth
|
||||
material), `APN_CONFIG_INVALID` (partial token auth),
|
||||
`APN_NOT_INITIALIZED` (send called after `shutdown()`), plus
|
||||
transport-level codes (`APN_TRANSPORT_INIT_FAILED`,
|
||||
`APN_TRANSPORT_SHUTDOWN_FAILED`).
|
||||
- Auto-initialise on first send — `sendNotification(...)` calls
|
||||
`initialize()` if the transport isn't live yet.
|
||||
- Bulk send batches in a single transport call so the underlying
|
||||
HTTP/2 connection is reused.
|
||||
- `isInitialized()` returns the live state so route handlers can
|
||||
short-circuit to `503` when push is disabled.
|
||||
- Default `pushType` resolution mirrors APN's contract: `"alert"` when
|
||||
the payload carries title or body, `"background"` when
|
||||
`contentAvailable` is set, otherwise `"alert"`. Callers may
|
||||
override.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
ApnService,
|
||||
createMemoryApnTransport,
|
||||
} from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
const service = new ApnService({
|
||||
config: {
|
||||
token: { key: "/keys/AuthKey_ABC.p8", keyId: "ABC", teamId: "TEAM" },
|
||||
production: false,
|
||||
defaultTopic: "com.example.app",
|
||||
},
|
||||
transportFactory: createMemoryApnTransport,
|
||||
});
|
||||
await service.initialize();
|
||||
const result = await service.sendNotification({
|
||||
deviceToken: "deadbeef",
|
||||
alert: { title: "Hi", body: "Welcome" },
|
||||
badge: 1,
|
||||
});
|
||||
console.log(result.sent, result.failed);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject a custom transport (e.g. a real `node-apn` adapter) and let the
|
||||
service own the validation + payload translation:
|
||||
|
||||
```ts
|
||||
import { ApnService, type ApnTransport } from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
class NodeApnTransport implements ApnTransport {
|
||||
async initialize() { /* … */ }
|
||||
async send(n, tokens) { /* … */ }
|
||||
async shutdown() { /* … */ }
|
||||
}
|
||||
|
||||
const service = new ApnService({
|
||||
config: parsedApnConfig,
|
||||
transport: new NodeApnTransport(),
|
||||
});
|
||||
const result = await service.sendBulkNotifications(
|
||||
["tok1", "tok2", "tok3"],
|
||||
{ alert: { title: "Bulk", body: "Send" } }
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ApnService` — class with `initialize` / `sendNotification` /
|
||||
`sendBulkNotifications` / `shutdown` / `isInitialized`.
|
||||
- `ApnServiceOptions` — `{ config, transportFactory?, transport? }`.
|
||||
- `PushNotificationPayload` — single-message payload.
|
||||
- `PushNotificationFailure` — per-device failure (`deviceToken`,
|
||||
`error`, optional `status` / `reason`).
|
||||
- `PushNotificationResult` — `{ sent, failed, errors? }`.
|
||||
- `createApnService(options)` — thin factory matching the upstream
|
||||
surface.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The service never imports `@parse/node-apn` directly — that library
|
||||
is deliberately absent from the catalog (network-touching
|
||||
adapters are injected by the host). Tests use the
|
||||
`MemoryApnTransport` with optional failure plans so every error
|
||||
branch is exercised without touching the network. Production hosts
|
||||
write a thin `NodeApnTransport` adapter against their existing
|
||||
`apn.Provider` and pass it in via `options.transport`.
|
||||
@@ -0,0 +1,519 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
NOTIFICATIONS_PLUGIN_CODES,
|
||||
NotificationsPluginError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { ApnConfigSchema } from "../../schemas/options.js";
|
||||
import {
|
||||
createMemoryApnTransport,
|
||||
MemoryApnTransport,
|
||||
} from "../../transport/apn-transport/apn-transport.js";
|
||||
import type { ApnTransport } from "../../transport/apn-transport/apn-transport.types.js";
|
||||
import { ApnService, createApnService } from "./apn-service.js";
|
||||
|
||||
const tokenConfig = ApnConfigSchema.parse({
|
||||
token: { key: "k", keyId: "i", teamId: "t" },
|
||||
defaultTopic: "com.example.app",
|
||||
});
|
||||
|
||||
describe("ApnService.initialize", () => {
|
||||
it("rejects missing auth material", { timeout: 5000 }, async () => {
|
||||
const s = new ApnService({
|
||||
config: ApnConfigSchema.parse({}),
|
||||
transportFactory: createMemoryApnTransport,
|
||||
});
|
||||
await expect(s.initialize()).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.APN_CONFIG_MISSING,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid token (missing keyId)", { timeout: 5000 }, async () => {
|
||||
const s = new ApnService({
|
||||
config: {
|
||||
token: { key: "k", keyId: "", teamId: "t" },
|
||||
production: false,
|
||||
} as never,
|
||||
transportFactory: createMemoryApnTransport,
|
||||
});
|
||||
await expect(s.initialize()).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.APN_CONFIG_INVALID,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid token (empty key)", { timeout: 5000 }, async () => {
|
||||
const s = new ApnService({
|
||||
config: {
|
||||
token: { key: "", keyId: "i", teamId: "t" },
|
||||
production: false,
|
||||
} as never,
|
||||
transportFactory: createMemoryApnTransport,
|
||||
});
|
||||
await expect(s.initialize()).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.APN_CONFIG_INVALID,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid token (empty teamId)", { timeout: 5000 }, async () => {
|
||||
const s = new ApnService({
|
||||
config: {
|
||||
token: { key: "k", keyId: "i", teamId: "" },
|
||||
production: false,
|
||||
} as never,
|
||||
transportFactory: createMemoryApnTransport,
|
||||
});
|
||||
await expect(s.initialize()).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.APN_CONFIG_INVALID,
|
||||
});
|
||||
});
|
||||
|
||||
it("constructs the transport via factory", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const s = createApnService({
|
||||
config: tokenConfig,
|
||||
transportFactory: createMemoryApnTransport,
|
||||
});
|
||||
await s.initialize();
|
||||
expect(s.isInitialized()).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts cert/key auth (no token)", { timeout: 5000 }, async () => {
|
||||
const cfg = ApnConfigSchema.parse({
|
||||
cert: "/path/cert.pem",
|
||||
key: "/path/key.pem",
|
||||
});
|
||||
const s = new ApnService({
|
||||
config: cfg,
|
||||
transportFactory: createMemoryApnTransport,
|
||||
});
|
||||
await s.initialize();
|
||||
expect(s.isInitialized()).toBe(true);
|
||||
});
|
||||
|
||||
it("wraps factory failure in APN_TRANSPORT_INIT_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const s = new ApnService({
|
||||
config: tokenConfig,
|
||||
transportFactory: () => {
|
||||
throw new Error("can't build socket");
|
||||
},
|
||||
});
|
||||
await expect(s.initialize()).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.APN_TRANSPORT_INIT_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps non-Error factory failure", { timeout: 5000 }, async () => {
|
||||
const s = new ApnService({
|
||||
config: tokenConfig,
|
||||
transportFactory: () => {
|
||||
// eslint-disable-next-line no-throw-literal
|
||||
throw "weird";
|
||||
},
|
||||
});
|
||||
await expect(s.initialize()).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.APN_TRANSPORT_INIT_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps non-Error shutdown failure", { timeout: 5000 }, async () => {
|
||||
const transport: ApnTransport = {
|
||||
async initialize() {},
|
||||
async send() {
|
||||
return { sent: [], failed: [] };
|
||||
},
|
||||
async shutdown() {
|
||||
// eslint-disable-next-line no-throw-literal
|
||||
throw "weird shutdown";
|
||||
},
|
||||
};
|
||||
const s = new ApnService({ config: tokenConfig, transport });
|
||||
await s.initialize();
|
||||
await expect(s.shutdown()).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.APN_TRANSPORT_SHUTDOWN_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("initialize is idempotent", { timeout: 5000 }, async () => {
|
||||
const s = new ApnService({
|
||||
config: tokenConfig,
|
||||
transportFactory: createMemoryApnTransport,
|
||||
});
|
||||
await s.initialize();
|
||||
await s.initialize();
|
||||
expect(s.isInitialized()).toBe(true);
|
||||
});
|
||||
|
||||
it("respects a pre-supplied transport", { timeout: 5000 }, async () => {
|
||||
const transport = new MemoryApnTransport(tokenConfig);
|
||||
const s = new ApnService({ config: tokenConfig, transport });
|
||||
await s.initialize();
|
||||
expect(s.isInitialized()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApnService.sendNotification", () => {
|
||||
it("returns sent=1 on success", { timeout: 5000 }, async () => {
|
||||
const s = new ApnService({
|
||||
config: tokenConfig,
|
||||
transportFactory: createMemoryApnTransport,
|
||||
});
|
||||
const res = await s.sendNotification({
|
||||
deviceToken: "tok",
|
||||
alert: { title: "Hi", body: "There" },
|
||||
badge: 1,
|
||||
sound: "default",
|
||||
category: "msg",
|
||||
threadId: "thread1",
|
||||
mutableContent: true,
|
||||
contentAvailable: true,
|
||||
priority: 10,
|
||||
expiry: 0,
|
||||
collapseId: "c1",
|
||||
customData: { key: "v" },
|
||||
topic: "override.topic",
|
||||
});
|
||||
expect(res.sent).toBe(1);
|
||||
expect(res.failed).toBe(0);
|
||||
});
|
||||
|
||||
it("maps failure into the result", { timeout: 5000 }, async () => {
|
||||
const plan = new Map([
|
||||
["bad", { status: 410, reason: "Unregistered" }],
|
||||
]);
|
||||
const s = new ApnService({
|
||||
config: tokenConfig,
|
||||
transport: new MemoryApnTransport(tokenConfig, plan),
|
||||
});
|
||||
const res = await s.sendNotification({
|
||||
deviceToken: "bad",
|
||||
alert: { body: "x" },
|
||||
});
|
||||
expect(res.failed).toBe(1);
|
||||
expect(res.errors?.[0]?.reason).toBe("Unregistered");
|
||||
expect(res.errors?.[0]?.status).toBe(410);
|
||||
});
|
||||
|
||||
it("maps transport-thrown error into a synthetic failure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transport: ApnTransport = {
|
||||
async initialize() {},
|
||||
async send() {
|
||||
throw new Error("network");
|
||||
},
|
||||
async shutdown() {},
|
||||
};
|
||||
const s = new ApnService({ config: tokenConfig, transport });
|
||||
const res = await s.sendNotification({
|
||||
deviceToken: "tok",
|
||||
alert: { body: "x" },
|
||||
});
|
||||
expect(res.failed).toBe(1);
|
||||
expect(res.errors?.[0]?.error).toBe("network");
|
||||
});
|
||||
|
||||
it("maps transport thrown non-Error into String() conversion", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transport: ApnTransport = {
|
||||
async initialize() {},
|
||||
async send() {
|
||||
// eslint-disable-next-line no-throw-literal
|
||||
throw "weird";
|
||||
},
|
||||
async shutdown() {},
|
||||
};
|
||||
const s = new ApnService({ config: tokenConfig, transport });
|
||||
const res = await s.sendNotification({
|
||||
deviceToken: "tok",
|
||||
alert: { body: "x" },
|
||||
});
|
||||
expect(res.errors?.[0]?.error).toBe("weird");
|
||||
});
|
||||
|
||||
it("picks pushType=background when contentAvailable only", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transport = new MemoryApnTransport(tokenConfig);
|
||||
const s = new ApnService({ config: tokenConfig, transport });
|
||||
await s.sendNotification({
|
||||
deviceToken: "tok",
|
||||
contentAvailable: true,
|
||||
});
|
||||
expect(transport.getLog()[0]?.notification.pushType).toBe("background");
|
||||
});
|
||||
|
||||
it("picks pushType=alert when alert.title set", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transport = new MemoryApnTransport(tokenConfig);
|
||||
const s = new ApnService({ config: tokenConfig, transport });
|
||||
await s.sendNotification({
|
||||
deviceToken: "tok",
|
||||
alert: { title: "T" },
|
||||
});
|
||||
expect(transport.getLog()[0]?.notification.pushType).toBe("alert");
|
||||
});
|
||||
|
||||
it("defaults pushType=alert when payload empty", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transport = new MemoryApnTransport(tokenConfig);
|
||||
const s = new ApnService({ config: tokenConfig, transport });
|
||||
await s.sendNotification({ deviceToken: "tok" });
|
||||
expect(transport.getLog()[0]?.notification.pushType).toBe("alert");
|
||||
});
|
||||
|
||||
it("omits topic when no defaultTopic and no payload topic", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const noTopic = ApnConfigSchema.parse({
|
||||
token: { key: "k", keyId: "i", teamId: "t" },
|
||||
});
|
||||
const transport = new MemoryApnTransport(noTopic);
|
||||
const s = new ApnService({ config: noTopic, transport });
|
||||
await s.sendNotification({
|
||||
deviceToken: "tok",
|
||||
alert: { body: "x" },
|
||||
});
|
||||
expect(transport.getLog()[0]?.notification.topic).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores alert object that has no title/subtitle/body", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transport = new MemoryApnTransport(tokenConfig);
|
||||
const s = new ApnService({ config: tokenConfig, transport });
|
||||
await s.sendNotification({ deviceToken: "tok", alert: {} });
|
||||
expect(transport.getLog()[0]?.notification.alert).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes subtitle in alert when set", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transport = new MemoryApnTransport(tokenConfig);
|
||||
const s = new ApnService({ config: tokenConfig, transport });
|
||||
await s.sendNotification({
|
||||
deviceToken: "tok",
|
||||
alert: { subtitle: "sub", body: "b" },
|
||||
});
|
||||
expect(transport.getLog()[0]?.notification.alert?.subtitle).toBe("sub");
|
||||
});
|
||||
|
||||
it("translates failure with no reason / status to Unknown APN failure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transport: ApnTransport = {
|
||||
async initialize() {},
|
||||
async send() {
|
||||
return {
|
||||
sent: [],
|
||||
failed: [{ device: "tok" }],
|
||||
};
|
||||
},
|
||||
async shutdown() {},
|
||||
};
|
||||
const s = new ApnService({ config: tokenConfig, transport });
|
||||
const res = await s.sendNotification({
|
||||
deviceToken: "tok",
|
||||
alert: { body: "x" },
|
||||
});
|
||||
expect(res.errors?.[0]?.error).toBe("Unknown APN failure");
|
||||
});
|
||||
|
||||
it("translates failure with native error to error.message", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transport: ApnTransport = {
|
||||
async initialize() {},
|
||||
async send() {
|
||||
return {
|
||||
sent: [],
|
||||
failed: [
|
||||
{
|
||||
device: "tok",
|
||||
error: new Error("native boom"),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
async shutdown() {},
|
||||
};
|
||||
const s = new ApnService({ config: tokenConfig, transport });
|
||||
const res = await s.sendNotification({
|
||||
deviceToken: "tok",
|
||||
});
|
||||
expect(res.errors?.[0]?.error).toBe("native boom");
|
||||
});
|
||||
|
||||
it("translates failure with status only to APN status N", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transport: ApnTransport = {
|
||||
async initialize() {},
|
||||
async send() {
|
||||
return {
|
||||
sent: [],
|
||||
failed: [{ device: "tok", status: 400 }],
|
||||
};
|
||||
},
|
||||
async shutdown() {},
|
||||
};
|
||||
const s = new ApnService({ config: tokenConfig, transport });
|
||||
const res = await s.sendNotification({
|
||||
deviceToken: "tok",
|
||||
});
|
||||
expect(res.errors?.[0]?.error).toBe("APN status 400");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApnService.sendBulkNotifications", () => {
|
||||
it("returns 0/0 on empty token list", { timeout: 5000 }, async () => {
|
||||
const s = new ApnService({
|
||||
config: tokenConfig,
|
||||
transportFactory: createMemoryApnTransport,
|
||||
});
|
||||
const res = await s.sendBulkNotifications([], {
|
||||
alert: { body: "x" },
|
||||
});
|
||||
expect(res.sent).toBe(0);
|
||||
expect(res.failed).toBe(0);
|
||||
});
|
||||
|
||||
it("aggregates per-device outcomes", { timeout: 5000 }, async () => {
|
||||
const plan = new Map([
|
||||
["bad", { status: 410, reason: "BadDeviceToken" }],
|
||||
]);
|
||||
const s = new ApnService({
|
||||
config: tokenConfig,
|
||||
transport: new MemoryApnTransport(tokenConfig, plan),
|
||||
});
|
||||
const res = await s.sendBulkNotifications(["ok", "bad"], {
|
||||
alert: { body: "x" },
|
||||
});
|
||||
expect(res.sent).toBe(1);
|
||||
expect(res.failed).toBe(1);
|
||||
});
|
||||
|
||||
it("maps thrown transport error to synthetic per-token failures", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transport: ApnTransport = {
|
||||
async initialize() {},
|
||||
async send() {
|
||||
throw new Error("disconnected");
|
||||
},
|
||||
async shutdown() {},
|
||||
};
|
||||
const s = new ApnService({ config: tokenConfig, transport });
|
||||
const res = await s.sendBulkNotifications(["a", "b"], {
|
||||
alert: { body: "x" },
|
||||
});
|
||||
expect(res.failed).toBe(2);
|
||||
expect(res.errors?.length).toBe(2);
|
||||
expect(res.errors?.[0]?.error).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("maps thrown non-Error transport failure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transport: ApnTransport = {
|
||||
async initialize() {},
|
||||
async send() {
|
||||
// eslint-disable-next-line no-throw-literal
|
||||
throw "weird";
|
||||
},
|
||||
async shutdown() {},
|
||||
};
|
||||
const s = new ApnService({ config: tokenConfig, transport });
|
||||
const res = await s.sendBulkNotifications(["a"], {
|
||||
alert: { body: "x" },
|
||||
});
|
||||
expect(res.errors?.[0]?.error).toBe("weird");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApnService.shutdown", () => {
|
||||
it("noop when not initialised", { timeout: 5000 }, async () => {
|
||||
const s = new ApnService({
|
||||
config: tokenConfig,
|
||||
transportFactory: createMemoryApnTransport,
|
||||
});
|
||||
await s.shutdown();
|
||||
expect(s.isInitialized()).toBe(false);
|
||||
});
|
||||
|
||||
it("flips initialized state", { timeout: 5000 }, async () => {
|
||||
const s = new ApnService({
|
||||
config: tokenConfig,
|
||||
transportFactory: createMemoryApnTransport,
|
||||
});
|
||||
await s.initialize();
|
||||
await s.shutdown();
|
||||
expect(s.isInitialized()).toBe(false);
|
||||
});
|
||||
|
||||
it("wraps transport.shutdown error in APN_TRANSPORT_SHUTDOWN_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transport: ApnTransport = {
|
||||
async initialize() {},
|
||||
async send() {
|
||||
return { sent: [], failed: [] };
|
||||
},
|
||||
async shutdown() {
|
||||
throw new Error("stuck");
|
||||
},
|
||||
};
|
||||
const s = new ApnService({ config: tokenConfig, transport });
|
||||
await s.initialize();
|
||||
await expect(s.shutdown()).rejects.toMatchObject({
|
||||
code: NOTIFICATIONS_PLUGIN_CODES.APN_TRANSPORT_SHUTDOWN_FAILED,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApnService — pre-shutdown re-init guard", () => {
|
||||
it("throws APN_NOT_INITIALIZED if transport remained null after init", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Construct via a factory that returns a transport which the
|
||||
// service itself never assigns — simulated by overriding
|
||||
// transport after init. Easier: use a tiny custom subclass.
|
||||
class NeverInit extends ApnService {
|
||||
async initialize() {
|
||||
// intentionally do not set transport — exercise the
|
||||
// guard branch.
|
||||
}
|
||||
}
|
||||
const svc = new NeverInit({
|
||||
config: tokenConfig,
|
||||
transportFactory: createMemoryApnTransport,
|
||||
});
|
||||
await expect(
|
||||
svc.sendNotification({
|
||||
deviceToken: "tok",
|
||||
alert: { body: "x" },
|
||||
})
|
||||
).rejects.toBeInstanceOf(NotificationsPluginError);
|
||||
});
|
||||
|
||||
it("sendBulk throws APN_NOT_INITIALIZED if init left transport null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
class NeverInit extends ApnService {
|
||||
async initialize() {
|
||||
// intentionally do not set transport
|
||||
}
|
||||
}
|
||||
const svc = new NeverInit({
|
||||
config: tokenConfig,
|
||||
transportFactory: createMemoryApnTransport,
|
||||
});
|
||||
await expect(
|
||||
svc.sendBulkNotifications(["a"], { alert: { body: "x" } })
|
||||
).rejects.toBeInstanceOf(NotificationsPluginError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import {
|
||||
NOTIFICATIONS_PLUGIN_CODES,
|
||||
NotificationsPluginError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import type { ApnConfig } from "../../schemas/options.js";
|
||||
import { createMemoryApnTransport } from "../../transport/apn-transport/apn-transport.js";
|
||||
import type {
|
||||
ApnTransport,
|
||||
ApnTransportNotification,
|
||||
ApnTransportResponse,
|
||||
} from "../../transport/apn-transport/apn-transport.types.js";
|
||||
import type {
|
||||
ApnServiceOptions,
|
||||
PushNotificationFailure,
|
||||
PushNotificationPayload,
|
||||
PushNotificationResult,
|
||||
} from "./apn-service.types.js";
|
||||
|
||||
/**
|
||||
* APN delivery service.
|
||||
*
|
||||
* Validates the config at construction-time, builds an
|
||||
* `ApnTransport` (defaulting to the in-memory deterministic one),
|
||||
* translates `PushNotificationPayload` values into the transport's
|
||||
* library-agnostic notification shape, and maps the transport's
|
||||
* response into the public `PushNotificationResult` shape. Per-device
|
||||
* failures NEVER throw — they appear inside the returned result.
|
||||
*/
|
||||
export class ApnService {
|
||||
protected readonly config: ApnConfig;
|
||||
protected transport: ApnTransport | null = null;
|
||||
private readonly transportFactory: (cfg: ApnConfig) => ApnTransport;
|
||||
|
||||
constructor(options: ApnServiceOptions) {
|
||||
this.config = options.config;
|
||||
this.transportFactory =
|
||||
options.transportFactory ?? createMemoryApnTransport;
|
||||
if (options.transport !== undefined) {
|
||||
// Pre-supplied transport (test path); skip factory at init.
|
||||
this.transport = options.transport;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate config and construct the transport. Safe to call
|
||||
* multiple times — the second call is a no-op.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.transport === null) {
|
||||
this.validateConfig();
|
||||
try {
|
||||
this.transport = this.transportFactory(this.config);
|
||||
} catch (cause) {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.APN_TRANSPORT_INIT_FAILED,
|
||||
`Failed to construct APN transport: ${
|
||||
cause instanceof Error ? cause.message : String(cause)
|
||||
}`,
|
||||
{
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
context: {
|
||||
production: this.config.production,
|
||||
topic: this.config.defaultTopic,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.transport.initialize();
|
||||
}
|
||||
|
||||
/** Send a single notification to one device. */
|
||||
async sendNotification(
|
||||
payload: PushNotificationPayload
|
||||
): Promise<PushNotificationResult> {
|
||||
if (this.transport === null) await this.initialize();
|
||||
const transport = this.transport;
|
||||
if (transport === null) {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.APN_NOT_INITIALIZED,
|
||||
"APN transport is not initialized after initialize() — refusing to send.",
|
||||
{ context: { deviceToken: payload.deviceToken } }
|
||||
);
|
||||
}
|
||||
|
||||
const notification = this.buildNotification(payload);
|
||||
let response: ApnTransportResponse;
|
||||
try {
|
||||
response = await transport.send(notification, payload.deviceToken);
|
||||
} catch (cause) {
|
||||
const message =
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
return {
|
||||
sent: 0,
|
||||
failed: 1,
|
||||
errors: [{ deviceToken: payload.deviceToken, error: message }],
|
||||
};
|
||||
}
|
||||
return this.mapResponse(response);
|
||||
}
|
||||
|
||||
/** Send the same notification to N devices in one transport call. */
|
||||
async sendBulkNotifications(
|
||||
deviceTokens: ReadonlyArray<string>,
|
||||
payload: Omit<PushNotificationPayload, "deviceToken">
|
||||
): Promise<PushNotificationResult> {
|
||||
if (deviceTokens.length === 0) {
|
||||
return { sent: 0, failed: 0 };
|
||||
}
|
||||
if (this.transport === null) await this.initialize();
|
||||
const transport = this.transport;
|
||||
if (transport === null) {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.APN_NOT_INITIALIZED,
|
||||
"APN transport is not initialized after initialize() — refusing bulk send."
|
||||
);
|
||||
}
|
||||
|
||||
const notification = this.buildNotification({
|
||||
...payload,
|
||||
deviceToken: deviceTokens[0] as string,
|
||||
});
|
||||
let response: ApnTransportResponse;
|
||||
try {
|
||||
response = await transport.send(notification, deviceTokens);
|
||||
} catch (cause) {
|
||||
const message =
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
const errors: PushNotificationFailure[] = deviceTokens.map(
|
||||
(deviceToken) => ({ deviceToken, error: message })
|
||||
);
|
||||
return { sent: 0, failed: deviceTokens.length, errors };
|
||||
}
|
||||
return this.mapResponse(response);
|
||||
}
|
||||
|
||||
/** Close the underlying transport; idempotent. */
|
||||
async shutdown(): Promise<void> {
|
||||
const transport = this.transport;
|
||||
if (transport === null) return;
|
||||
this.transport = null;
|
||||
try {
|
||||
await transport.shutdown();
|
||||
} catch (cause) {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.APN_TRANSPORT_SHUTDOWN_FAILED,
|
||||
`Failed to cleanly shutdown APN transport: ${
|
||||
cause instanceof Error ? cause.message : String(cause)
|
||||
}`,
|
||||
{ cause: cause instanceof Error ? cause : undefined }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** `true` once `initialize()` has constructed the transport. */
|
||||
isInitialized(): boolean {
|
||||
return this.transport !== null;
|
||||
}
|
||||
|
||||
/** Throws when the config is missing or contradictory. */
|
||||
protected validateConfig(): void {
|
||||
const hasCert = Boolean(this.config.cert);
|
||||
const hasKey = Boolean(this.config.key);
|
||||
const hasPfx = Boolean(this.config.pfx);
|
||||
const hasToken = Boolean(this.config.token);
|
||||
|
||||
if (!hasCert && !hasKey && !hasPfx && !hasToken) {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.APN_CONFIG_MISSING,
|
||||
"APN configuration requires either cert/key, pfx, or token authentication"
|
||||
);
|
||||
}
|
||||
|
||||
if (hasToken) {
|
||||
const token = this.config.token as NonNullable<
|
||||
typeof this.config.token
|
||||
>;
|
||||
if (
|
||||
String(token.key).length === 0 ||
|
||||
token.keyId.length === 0 ||
|
||||
token.teamId.length === 0
|
||||
) {
|
||||
throw new NotificationsPluginError(
|
||||
NOTIFICATIONS_PLUGIN_CODES.APN_CONFIG_INVALID,
|
||||
"APN token authentication requires non-empty key, keyId, and teamId"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Translate our payload to the transport's notification shape. */
|
||||
protected buildNotification(
|
||||
payload: PushNotificationPayload
|
||||
): ApnTransportNotification {
|
||||
const note: ApnTransportNotification = {};
|
||||
const topic = payload.topic ?? this.config.defaultTopic;
|
||||
if (topic !== undefined) note.topic = topic;
|
||||
if (payload.alert !== undefined) {
|
||||
const { title, subtitle, body } = payload.alert;
|
||||
if (
|
||||
title !== undefined ||
|
||||
subtitle !== undefined ||
|
||||
body !== undefined
|
||||
) {
|
||||
note.alert = { body: body ?? "" };
|
||||
if (title !== undefined) note.alert.title = title;
|
||||
if (subtitle !== undefined) note.alert.subtitle = subtitle;
|
||||
}
|
||||
}
|
||||
if (payload.badge !== undefined) note.badge = payload.badge;
|
||||
if (payload.sound !== undefined) note.sound = payload.sound;
|
||||
if (payload.category !== undefined) note.category = payload.category;
|
||||
if (payload.threadId !== undefined) note.threadId = payload.threadId;
|
||||
if (payload.mutableContent !== undefined)
|
||||
note.mutableContent = payload.mutableContent;
|
||||
if (payload.contentAvailable !== undefined)
|
||||
note.contentAvailable = payload.contentAvailable;
|
||||
if (payload.priority !== undefined) note.priority = payload.priority;
|
||||
if (payload.expiry !== undefined) note.expiry = payload.expiry;
|
||||
if (payload.collapseId !== undefined)
|
||||
note.collapseId = payload.collapseId;
|
||||
note.pushType =
|
||||
payload.pushType ??
|
||||
(payload.alert &&
|
||||
(payload.alert.body !== undefined ||
|
||||
payload.alert.title !== undefined)
|
||||
? "alert"
|
||||
: payload.contentAvailable === true
|
||||
? "background"
|
||||
: "alert");
|
||||
if (payload.customData !== undefined) note.payload = payload.customData;
|
||||
return note;
|
||||
}
|
||||
|
||||
/** Fold the transport's response into our public result shape. */
|
||||
protected mapResponse(
|
||||
response: ApnTransportResponse
|
||||
): PushNotificationResult {
|
||||
const errors: PushNotificationFailure[] = response.failed.map(
|
||||
(failure) => {
|
||||
const apnReason = failure.response?.reason;
|
||||
const nativeError = failure.error?.message;
|
||||
const message =
|
||||
apnReason ??
|
||||
nativeError ??
|
||||
(failure.status !== undefined
|
||||
? `APN status ${failure.status}`
|
||||
: "Unknown APN failure");
|
||||
const entry: PushNotificationFailure = {
|
||||
deviceToken: failure.device,
|
||||
error: message,
|
||||
};
|
||||
if (failure.status !== undefined) entry.status = failure.status;
|
||||
if (apnReason !== undefined) entry.reason = apnReason;
|
||||
return entry;
|
||||
}
|
||||
);
|
||||
const result: PushNotificationResult = {
|
||||
sent: response.sent.length,
|
||||
failed: response.failed.length,
|
||||
};
|
||||
if (errors.length > 0) result.errors = errors;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/** Thin factory matching the upstream public API. */
|
||||
export function createApnService(options: ApnServiceOptions): ApnService {
|
||||
return new ApnService(options);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { ApnConfig } from "../../schemas/options.js";
|
||||
import type {
|
||||
ApnTransport,
|
||||
ApnTransportFactory,
|
||||
} from "../../transport/apn-transport/apn-transport.types.js";
|
||||
|
||||
/** Single push payload accepted by `ApnService.sendNotification`. */
|
||||
export interface PushNotificationPayload {
|
||||
/** Hex-encoded device token. */
|
||||
deviceToken: string;
|
||||
/** Alert strings shown on the lock screen / notification center. */
|
||||
alert?: { title?: string; body?: string; subtitle?: string };
|
||||
/** Badge number; `0` clears the badge. */
|
||||
badge?: number;
|
||||
/** Sound filename, `"default"`, or a critical-alert sound object. */
|
||||
sound?: string | { critical: 0 | 1; name: string; volume: number };
|
||||
/** Arbitrary JSON delivered alongside the `aps` payload. */
|
||||
customData?: Record<string, unknown>;
|
||||
/** Overrides `ApnConfig.defaultTopic`. */
|
||||
topic?: string;
|
||||
/** Category id (used for actionable notifications). */
|
||||
category?: string;
|
||||
/** Thread id for grouping related notifications. */
|
||||
threadId?: string;
|
||||
/** Mutable-content flag. */
|
||||
mutableContent?: boolean;
|
||||
/** Content-available flag for silent / background pushes. */
|
||||
contentAvailable?: boolean;
|
||||
/** Push priority: `10` = immediate, `5` = power-conserving. */
|
||||
priority?: 5 | 10;
|
||||
/** Epoch seconds at which APN may drop the notification. */
|
||||
expiry?: number;
|
||||
/** Collapse id to deduplicate replaceable notifications. */
|
||||
collapseId?: string;
|
||||
/** APN push type. */
|
||||
pushType?:
|
||||
| "alert"
|
||||
| "background"
|
||||
| "voip"
|
||||
| "pushtotalk"
|
||||
| "liveactivity"
|
||||
| "location"
|
||||
| "complication"
|
||||
| "fileprovider"
|
||||
| "mdm";
|
||||
}
|
||||
|
||||
/** Per-device failure inside a `PushNotificationResult`. */
|
||||
export interface PushNotificationFailure {
|
||||
deviceToken: string;
|
||||
error: string;
|
||||
status?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Aggregate outcome of a send. */
|
||||
export interface PushNotificationResult {
|
||||
sent: number;
|
||||
failed: number;
|
||||
errors?: PushNotificationFailure[];
|
||||
}
|
||||
|
||||
/** Construction options for `ApnService`. */
|
||||
export interface ApnServiceOptions {
|
||||
/** Validated APN config (use `ApnConfigSchema.parse(...)`). */
|
||||
config: ApnConfig;
|
||||
/**
|
||||
* Transport factory. Default: `createMemoryApnTransport` — the
|
||||
* deterministic in-memory transport that ships with the plugin.
|
||||
* Hosts inject `(cfg) => new NodeApnTransport(cfg)` for real
|
||||
* `node-apn`-backed delivery.
|
||||
*/
|
||||
transportFactory?: ApnTransportFactory;
|
||||
/** Pre-built transport (bypasses the factory; used in tests). */
|
||||
transport?: ApnTransport;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { ApnService, createApnService } from "./apn-service.js";
|
||||
export type {
|
||||
ApnServiceOptions,
|
||||
PushNotificationFailure,
|
||||
PushNotificationPayload,
|
||||
PushNotificationResult,
|
||||
} from "./apn-service.types.js";
|
||||
@@ -0,0 +1,62 @@
|
||||
# `services/registry-augmentation`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Module-augmentation file that declares three new service slots on
|
||||
`@suaveplan/api-plugin/service-registry`'s `ServiceRegistry`
|
||||
interface: `notificationRepository`, `deviceTokenRepository`, and
|
||||
`apnService`. Without this augmentation the typed `ctx.getService(...)`
|
||||
helper would return `unknown` for these keys; with it, downstream
|
||||
plugins resolve the slots with no casts.
|
||||
|
||||
## Features
|
||||
|
||||
- Cumulative module augmentation — the file only declares the slots
|
||||
this plugin owns. Host slots (`app`, `db`, `logger`, `eventBus`,
|
||||
`clock`) are declared by `@suaveplan/api`'s own augmentation; the
|
||||
TypeScript compiler merges every declaration in the program into a
|
||||
single `ServiceRegistry` shape.
|
||||
- Side-effect import — the package barrel re-imports this module via
|
||||
`import "./services/registry-augmentation.js"` so any consumer
|
||||
that pulls a single public symbol from `@suaveplan/api-plugin-notifications`
|
||||
gets the type-only augmentation automatically.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import "@suaveplan/api-plugin-notifications";
|
||||
|
||||
const repo = ctx.getService("notificationRepository");
|
||||
// repo is INotificationRepository | undefined — no cast.
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
When using a downstream plugin that depends on the notifications
|
||||
plugin's typed registry, the registry import flows transitively
|
||||
through the side-effect import:
|
||||
|
||||
```ts
|
||||
import { createMyDownstreamPlugin } from "@my-org/api-plugin-downstream";
|
||||
import "@suaveplan/api-plugin-notifications";
|
||||
|
||||
// Downstream plugin's own augmentation merges with this one — both
|
||||
// `myService` and `notificationRepository` resolve typed.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
There is no runtime export — the file only declares
|
||||
`declare module "@suaveplan/api-plugin/service-registry"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The file deliberately re-declares the host slots `app` and `db` so
|
||||
intellisense never claims they are absent in a context that imports
|
||||
only this plugin. Module augmentation is cumulative, so the
|
||||
re-declaration merges with `@suaveplan/api`'s own augmentation rather
|
||||
than overriding it.
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* ServiceRegistry module augmentation — declares the
|
||||
* `notificationRepository`, `deviceTokenRepository`, and `apnService`
|
||||
* slots on `@suaveplan/api-plugin/service-registry`'s ServiceRegistry
|
||||
* interface so downstream plugins typed against the augmented
|
||||
* registry resolve them without casts.
|
||||
*/
|
||||
import type { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import type { EmailService } from "@suaveplan/emailer";
|
||||
import type { NotificationDispatcher } from "../dispatcher/notification-dispatcher/notification-dispatcher.js";
|
||||
import type { IDeviceTokenRepository } from "../repositories/device-token-repository/device-token-repository.types.js";
|
||||
import type { INotificationRepository } from "../repositories/notification-repository/notification-repository.types.js";
|
||||
import type { ApnService } from "./apn-service/apn-service.js";
|
||||
|
||||
declare module "@suaveplan/api-plugin/service-registry" {
|
||||
interface ServiceRegistry {
|
||||
notificationRepository: INotificationRepository;
|
||||
deviceTokenRepository: IDeviceTokenRepository;
|
||||
apnService: ApnService;
|
||||
notificationDispatcher: NotificationDispatcher;
|
||||
emailService: EmailService;
|
||||
app: OpenAPIHono;
|
||||
db?: import("@suaveplan/surrealdb").SurrealAdapter;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
# `transport/apn-transport`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Pluggable transport contract for Apple Push Notification delivery. The
|
||||
plugin never depends on a concrete APN client library; hosts inject an
|
||||
`ApnTransport` implementation — production wires in
|
||||
`@parse/node-apn`, tests use the deterministic
|
||||
`MemoryApnTransport`, and bespoke deployments may target an on-prem
|
||||
APN gateway. This module owns both the interface
|
||||
(`apn-transport.types.ts`) and the in-memory default that ships with
|
||||
the plugin (`MemoryApnTransport`, `createMemoryApnTransport`).
|
||||
|
||||
## Features
|
||||
|
||||
- Library-agnostic `ApnTransportResponse` shape — mirrors the
|
||||
upstream `apn.Responses` envelope but never re-exports `node-apn`'s
|
||||
internal types.
|
||||
- Per-device failure injection — the memory transport accepts a
|
||||
`failurePlan: Map<deviceToken, {status, reason, error}>` so tests
|
||||
reproduce bad-token / unregistered / unavailable APN responses
|
||||
deterministically without touching the network.
|
||||
- Send log — every notification + token list is captured so tests can
|
||||
assert delivery shape, ordering, and TTL.
|
||||
- Idempotent `initialize()` and `shutdown()` — the memory transport
|
||||
flips a boolean; a real `node-apn` transport would open / close the
|
||||
HTTP/2 connection.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
MemoryApnTransport,
|
||||
createMemoryApnTransport,
|
||||
} from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
const transport = createMemoryApnTransport({
|
||||
token: { key: "/keys/AuthKey_ABC.p8", keyId: "ABC", teamId: "TEAM" },
|
||||
production: false,
|
||||
});
|
||||
await transport.initialize();
|
||||
const result = await transport.send(
|
||||
{ alert: { title: "Hi", body: "Welcome" } },
|
||||
["deadbeef"]
|
||||
);
|
||||
console.log(result.sent.length, result.failed.length);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject a failure plan to exercise APN error paths:
|
||||
|
||||
```ts
|
||||
const failures = new Map([
|
||||
[
|
||||
"badtoken",
|
||||
{ status: 410, reason: "Unregistered" },
|
||||
],
|
||||
]);
|
||||
const transport = new MemoryApnTransport(config, failures);
|
||||
const result = await transport.send(notification, ["good", "badtoken"]);
|
||||
expect(result.sent).toEqual([{ device: "good" }]);
|
||||
expect(result.failed[0]?.response?.reason).toBe("Unregistered");
|
||||
```
|
||||
|
||||
Hosts wiring in `node-apn` for production write a thin adapter that
|
||||
implements `ApnTransport` against their existing `apn.Provider`:
|
||||
|
||||
```ts
|
||||
class NodeApnTransport implements ApnTransport {
|
||||
constructor(private provider: apn.Provider) {}
|
||||
async initialize() { /* noop */ }
|
||||
async send(n, tokens) {
|
||||
const note = new apn.Notification();
|
||||
Object.assign(note, n);
|
||||
const res = await this.provider.send(
|
||||
note,
|
||||
Array.isArray(tokens) ? tokens : [tokens]
|
||||
);
|
||||
return { sent: res.sent, failed: res.failed };
|
||||
}
|
||||
async shutdown() { await this.provider.shutdown(); }
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ApnTransport` — interface (initialize / send / shutdown).
|
||||
- `ApnTransportNotification` — library-agnostic push payload.
|
||||
- `ApnTransportResponse` — library-agnostic response envelope.
|
||||
- `ApnTransportFactory` — `(config: ApnConfig) => ApnTransport`.
|
||||
- `MemoryApnTransport` — concrete deterministic implementation.
|
||||
- `createMemoryApnTransport` — default factory.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The plugin uses dependency inversion to keep `@parse/node-apn` (and
|
||||
its native build dependencies) out of the project's build graph. The
|
||||
suaveplan catalog deliberately omits `@parse/node-apn`; hosts that need
|
||||
real APN delivery add it as an application dependency and pass in
|
||||
their own transport. This matches the suaveplan convention for
|
||||
network-touching adapters and means the plugin's test suite runs
|
||||
hermetically.
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { ApnConfigSchema } from "../../schemas/options.js";
|
||||
import {
|
||||
createMemoryApnTransport,
|
||||
MemoryApnTransport,
|
||||
} from "./apn-transport.js";
|
||||
|
||||
const baseConfig = ApnConfigSchema.parse({
|
||||
token: { key: "k", keyId: "i", teamId: "t" },
|
||||
defaultTopic: "com.example.app",
|
||||
});
|
||||
|
||||
describe("MemoryApnTransport", () => {
|
||||
it("initialize is idempotent", { timeout: 5000 }, async () => {
|
||||
const t = new MemoryApnTransport(baseConfig);
|
||||
await t.initialize();
|
||||
await t.initialize();
|
||||
expect(t.getConfig()).toBe(baseConfig);
|
||||
});
|
||||
|
||||
it("send returns sent for every token by default", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = createMemoryApnTransport(baseConfig);
|
||||
const res = await t.send({ alert: { title: "T", body: "B" } }, [
|
||||
"a",
|
||||
"b",
|
||||
]);
|
||||
expect(res.sent.length).toBe(2);
|
||||
expect(res.failed.length).toBe(0);
|
||||
});
|
||||
|
||||
it("send accepts a single token string", { timeout: 5000 }, async () => {
|
||||
const t = new MemoryApnTransport(baseConfig);
|
||||
const res = await t.send({ alert: { body: "x" } }, "tok");
|
||||
expect(res.sent[0]?.device).toBe("tok");
|
||||
});
|
||||
|
||||
it("send routes failures via failurePlan with status / reason / error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plan = new Map([
|
||||
["bad", { status: 410, reason: "Unregistered" }],
|
||||
["fatal", { error: "transport blew up" }],
|
||||
]);
|
||||
const t = new MemoryApnTransport(baseConfig, plan);
|
||||
const res = await t.send({ alert: { body: "x" } }, [
|
||||
"good",
|
||||
"bad",
|
||||
"fatal",
|
||||
]);
|
||||
expect(res.sent.length).toBe(1);
|
||||
expect(res.failed.length).toBe(2);
|
||||
const reasons = res.failed.map((f) => f.response?.reason);
|
||||
expect(reasons).toContain("Unregistered");
|
||||
const errors = res.failed.map((f) => f.error?.message);
|
||||
expect(errors).toContain("transport blew up");
|
||||
});
|
||||
|
||||
it("send is captured in the log", { timeout: 5000 }, async () => {
|
||||
const t = new MemoryApnTransport(baseConfig);
|
||||
await t.send({ alert: { body: "x" } }, ["a"]);
|
||||
expect(t.getLog().length).toBe(1);
|
||||
expect(t.getLog()[0]?.deviceTokens).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("shutdown flips initialised state", { timeout: 5000 }, async () => {
|
||||
const t = new MemoryApnTransport(baseConfig);
|
||||
await t.initialize();
|
||||
await t.shutdown();
|
||||
// After shutdown a subsequent send still works (re-init).
|
||||
const res = await t.send({ alert: { body: "x" } }, "a");
|
||||
expect(res.sent.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { ApnConfig } from "../../schemas/options.js";
|
||||
import type {
|
||||
ApnTransport,
|
||||
ApnTransportNotification,
|
||||
ApnTransportResponse,
|
||||
} from "./apn-transport.types.js";
|
||||
|
||||
/**
|
||||
* Deterministic in-memory APN transport.
|
||||
*
|
||||
* Used as the default when the host does not inject its own transport.
|
||||
* Records every send into an internal log so tests can assert
|
||||
* delivery without touching the network. Production deployments MUST
|
||||
* inject a real transport that talks to `api.push.apple.com` — the
|
||||
* memory transport synthesises a successful response for every token
|
||||
* and never reaches Apple.
|
||||
*/
|
||||
export class MemoryApnTransport implements ApnTransport {
|
||||
private readonly config: ApnConfig;
|
||||
private initialized = false;
|
||||
private readonly log: Array<{
|
||||
notification: ApnTransportNotification;
|
||||
deviceTokens: ReadonlyArray<string>;
|
||||
}> = [];
|
||||
/** Optional injected failure plan for tests. */
|
||||
private readonly failurePlan: ReadonlyMap<
|
||||
string,
|
||||
{ status?: number; reason?: string; error?: string }
|
||||
>;
|
||||
|
||||
constructor(
|
||||
config: ApnConfig,
|
||||
failurePlan?: ReadonlyMap<
|
||||
string,
|
||||
{ status?: number; reason?: string; error?: string }
|
||||
>
|
||||
) {
|
||||
this.config = config;
|
||||
this.failurePlan = failurePlan ?? new Map();
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
// Boundary validation already happened at schema parse; this
|
||||
// is the place where a real transport would open its socket.
|
||||
// The memory transport simply flips the flag.
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
async send(
|
||||
notification: ApnTransportNotification,
|
||||
deviceTokens: string | ReadonlyArray<string>
|
||||
): Promise<ApnTransportResponse> {
|
||||
if (!this.initialized) await this.initialize();
|
||||
const tokens =
|
||||
typeof deviceTokens === "string" ? [deviceTokens] : deviceTokens;
|
||||
this.log.push({ notification, deviceTokens: tokens });
|
||||
|
||||
const sent: Array<{ device: string }> = [];
|
||||
const failed: Array<{
|
||||
device: string;
|
||||
status?: number;
|
||||
response?: { reason?: string };
|
||||
error?: Error;
|
||||
}> = [];
|
||||
for (const device of tokens) {
|
||||
const plan = this.failurePlan.get(device);
|
||||
if (plan === undefined) {
|
||||
sent.push({ device });
|
||||
continue;
|
||||
}
|
||||
const entry: {
|
||||
device: string;
|
||||
status?: number;
|
||||
response?: { reason?: string };
|
||||
error?: Error;
|
||||
} = { device };
|
||||
if (plan.status !== undefined) entry.status = plan.status;
|
||||
if (plan.reason !== undefined)
|
||||
entry.response = { reason: plan.reason };
|
||||
if (plan.error !== undefined) entry.error = new Error(plan.error);
|
||||
failed.push(entry);
|
||||
}
|
||||
return { sent, failed };
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
/** Snapshot of every send recorded since construction. */
|
||||
getLog(): ReadonlyArray<{
|
||||
notification: ApnTransportNotification;
|
||||
deviceTokens: ReadonlyArray<string>;
|
||||
}> {
|
||||
return [...this.log];
|
||||
}
|
||||
|
||||
/** Test helper — read the config back. */
|
||||
getConfig(): ApnConfig {
|
||||
return this.config;
|
||||
}
|
||||
}
|
||||
|
||||
/** Default factory — builds a `MemoryApnTransport` from a parsed config. */
|
||||
export function createMemoryApnTransport(config: ApnConfig): ApnTransport {
|
||||
return new MemoryApnTransport(config);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { ApnConfig } from "../../schemas/options.js";
|
||||
|
||||
/**
|
||||
* Aggregate outcome of an `ApnTransport.send(...)` call.
|
||||
*
|
||||
* Mirrors the upstream `apn.Responses<ResponseSent, ResponseFailure>`
|
||||
* shape but is library-agnostic so the plugin's public surface never
|
||||
* depends on `@parse/node-apn`'s internal types. Hosts that wire in
|
||||
* the real `node-apn` provider translate between the two at the edge.
|
||||
*/
|
||||
export interface ApnTransportResponse {
|
||||
/** Per-device tokens APN accepted. */
|
||||
readonly sent: ReadonlyArray<{ device: string }>;
|
||||
/** Per-device failures (token rejected, status, reason). */
|
||||
readonly failed: ReadonlyArray<{
|
||||
device: string;
|
||||
status?: number;
|
||||
response?: { reason?: string };
|
||||
error?: Error;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** A single push request that the transport must deliver. */
|
||||
export interface ApnTransportNotification {
|
||||
/** APN topic (bundle id). */
|
||||
topic?: string;
|
||||
/** Alert payload shaped per APN spec. */
|
||||
alert?: { title?: string; body?: string; subtitle?: string };
|
||||
/** Badge number (0 clears). */
|
||||
badge?: number;
|
||||
/** Sound name or critical-alert object. */
|
||||
sound?: string | { critical: 0 | 1; name: string; volume: number };
|
||||
/** Category id for actionable notifications. */
|
||||
category?: string;
|
||||
/** Thread id for notification grouping. */
|
||||
threadId?: string;
|
||||
/** Mutable-content flag for NSE-style modification. */
|
||||
mutableContent?: boolean;
|
||||
/** Content-available flag for silent / background pushes. */
|
||||
contentAvailable?: boolean;
|
||||
/** APN priority (10 = immediate, 5 = power-conserving). */
|
||||
priority?: 5 | 10;
|
||||
/** Drop-after epoch seconds. */
|
||||
expiry?: number;
|
||||
/** Collapse id for de-dup. */
|
||||
collapseId?: string;
|
||||
/** APN push type. */
|
||||
pushType?:
|
||||
| "alert"
|
||||
| "background"
|
||||
| "voip"
|
||||
| "pushtotalk"
|
||||
| "liveactivity"
|
||||
| "location"
|
||||
| "complication"
|
||||
| "fileprovider"
|
||||
| "mdm";
|
||||
/** Arbitrary JSON payload extra to the `aps` envelope. */
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pluggable transport for the APN service.
|
||||
*
|
||||
* The plugin never depends on a concrete APN client library; hosts
|
||||
* pass in an implementation that wraps `@parse/node-apn` (production),
|
||||
* a deterministic in-memory implementation (tests), or a custom
|
||||
* sender (on-prem APN gateway). This is the genesis-canonical
|
||||
* injectable-adapter pattern.
|
||||
*/
|
||||
export interface ApnTransport {
|
||||
/** Idempotent setup; the service calls this on first send. */
|
||||
initialize(): Promise<void>;
|
||||
/**
|
||||
* Send a single notification to one or many device tokens.
|
||||
*
|
||||
* The transport NEVER throws for per-device failures — those land
|
||||
* in the `failed` array. It MAY throw for fatal transport errors
|
||||
* (e.g. the underlying socket is unreachable); the APN service
|
||||
* translates that into a synthetic per-device failure result so
|
||||
* callers never see raw exceptions.
|
||||
*/
|
||||
send(
|
||||
notification: ApnTransportNotification,
|
||||
deviceTokens: string | ReadonlyArray<string>
|
||||
): Promise<ApnTransportResponse>;
|
||||
/** Close the underlying connection; idempotent. */
|
||||
shutdown(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Factory signature: callers build a transport from a validated config. */
|
||||
export type ApnTransportFactory = (config: ApnConfig) => ApnTransport;
|
||||
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
createMemoryApnTransport,
|
||||
MemoryApnTransport,
|
||||
} from "./apn-transport.js";
|
||||
export type {
|
||||
ApnTransport,
|
||||
ApnTransportFactory,
|
||||
ApnTransportNotification,
|
||||
ApnTransportResponse,
|
||||
} from "./apn-transport.types.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
NotificationsPluginEvents,
|
||||
NotificationsRouteEvent,
|
||||
NotificationsRoutesConfig,
|
||||
PushRoutesConfig,
|
||||
} from "./types.js";
|
||||
@@ -0,0 +1,76 @@
|
||||
# `types/types`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Public type aliases for cross-module wiring: the typed event-bus
|
||||
payload map (`NotificationsPluginEvents`), the discriminated union of
|
||||
domain events the routes forward (`NotificationsRouteEvent`), and the
|
||||
config interfaces handed to the route factories
|
||||
(`NotificationsRoutesConfig`, `PushRoutesConfig`). These types are
|
||||
re-exported from the package root so external subscribers gain typed
|
||||
`eventBus.on(topic, handler)` calls without casts.
|
||||
|
||||
## Features
|
||||
|
||||
- Eight typed event keys spanning lifecycle, CRUD mutations, push
|
||||
delivery, and device-token registration.
|
||||
- Discriminated route-event union — the route layer constructs one of
|
||||
the eight `{ type, payload }` shapes and the plugin's forwarder
|
||||
callback turns it into `ctx.eventBus.emit(type, payload)`.
|
||||
- Module-augmentation contract is documented inline so consumers
|
||||
reading the public type alias understand that subscribers receive
|
||||
full payload typing through
|
||||
`@suaveplan/api-plugin/service-registry`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type {
|
||||
NotificationsPluginEvents,
|
||||
NotificationsRouteEvent,
|
||||
} from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
const event: NotificationsRouteEvent = {
|
||||
type: "notification:read",
|
||||
payload: { notificationId: "notification:1", recipientId: "user:1" },
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
A sibling plugin subscribing to push-failure events:
|
||||
|
||||
```ts
|
||||
const offFail = ctx.eventBus.on(
|
||||
"notification:push:failed",
|
||||
(payload: NotificationsPluginEvents["notification:push:failed"]) => {
|
||||
if (payload.reason === "BadDeviceToken") {
|
||||
deactivate(payload.deviceToken);
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NotificationsPluginEvents` — keyed by event name, value is the
|
||||
payload object.
|
||||
- `NotificationsRouteEvent` — discriminated union with eight variants.
|
||||
- `NotificationsRoutesConfig` —
|
||||
`{ notificationRepository, emitEvent? }`.
|
||||
- `PushRoutesConfig` —
|
||||
`{ deviceTokenRepository, apnService, maxBulkRecipients, emitEvent? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `NotificationsPluginEvents` map deliberately follows the
|
||||
`area:action` and `area:subarea:action` conventions used by sibling
|
||||
plugins (`events:initialized`, `event:created`,
|
||||
`notification:push:sent`). The discriminated union mirrors the map
|
||||
exactly so the forwarder callback can route to `eventBus.emit` without
|
||||
runtime type guards — TypeScript narrows on the literal `type` field
|
||||
at compile time.
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { IDeviceTokenRepository } from "../repositories/device-token-repository/device-token-repository.types.js";
|
||||
import type { INotificationRepository } from "../repositories/notification-repository/notification-repository.types.js";
|
||||
import type { ApnService } from "../services/apn-service/apn-service.js";
|
||||
|
||||
/**
|
||||
* Typed event payloads emitted on the host's shared `ctx.eventBus`.
|
||||
*
|
||||
* Subscribers receive these payloads with full typing through module
|
||||
* augmentation on `@suaveplan/api-plugin`. Sibling plugins
|
||||
* (audit-log, search-index, websocket-fanout) subscribe through
|
||||
* `ctx.eventBus.on(topic, handler)`.
|
||||
*
|
||||
* Note: the upstream package used a local `EventEmitter` constructed
|
||||
* inside the plugin factory which was invisible to siblings. This
|
||||
* version routes everything through `ctx.eventBus` per the
|
||||
* api-plugin-events pattern (see `EventsRouteEvent`).
|
||||
*/
|
||||
export interface NotificationsPluginEvents {
|
||||
"notifications:initialized": undefined;
|
||||
"notifications:ready": undefined;
|
||||
"notification:created": {
|
||||
notificationId: string;
|
||||
recipientId: string;
|
||||
type: string;
|
||||
};
|
||||
"notification:read": {
|
||||
notificationId: string;
|
||||
recipientId: string;
|
||||
};
|
||||
"notification:dismissed": {
|
||||
notificationId: string;
|
||||
recipientId: string;
|
||||
};
|
||||
"notification:deleted": {
|
||||
notificationId: string;
|
||||
recipientId: string;
|
||||
};
|
||||
"notification:push:sent": {
|
||||
userId: string;
|
||||
deviceToken: string;
|
||||
platform: string;
|
||||
};
|
||||
"notification:push:failed": {
|
||||
userId: string;
|
||||
deviceToken: string;
|
||||
reason: string;
|
||||
};
|
||||
"notification:device:registered": {
|
||||
userId: string;
|
||||
deviceTokenId: string;
|
||||
platform: string;
|
||||
};
|
||||
"notification:device:unregistered": {
|
||||
userId: string;
|
||||
deviceTokenId: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Discriminated union forwarded by the route layer onto `ctx.eventBus`. */
|
||||
export type NotificationsRouteEvent =
|
||||
| {
|
||||
type: "notification:created";
|
||||
payload: NotificationsPluginEvents["notification:created"];
|
||||
}
|
||||
| {
|
||||
type: "notification:read";
|
||||
payload: NotificationsPluginEvents["notification:read"];
|
||||
}
|
||||
| {
|
||||
type: "notification:dismissed";
|
||||
payload: NotificationsPluginEvents["notification:dismissed"];
|
||||
}
|
||||
| {
|
||||
type: "notification:deleted";
|
||||
payload: NotificationsPluginEvents["notification:deleted"];
|
||||
}
|
||||
| {
|
||||
type: "notification:push:sent";
|
||||
payload: NotificationsPluginEvents["notification:push:sent"];
|
||||
}
|
||||
| {
|
||||
type: "notification:push:failed";
|
||||
payload: NotificationsPluginEvents["notification:push:failed"];
|
||||
}
|
||||
| {
|
||||
type: "notification:device:registered";
|
||||
payload: NotificationsPluginEvents["notification:device:registered"];
|
||||
}
|
||||
| {
|
||||
type: "notification:device:unregistered";
|
||||
payload: NotificationsPluginEvents["notification:device:unregistered"];
|
||||
};
|
||||
|
||||
/** Dependencies handed to `createNotificationsRoutes`. */
|
||||
export interface NotificationsRoutesConfig {
|
||||
notificationRepository: INotificationRepository;
|
||||
emitEvent?: (event: NotificationsRouteEvent) => void;
|
||||
}
|
||||
|
||||
/** Dependencies handed to `createPushRoutes`. */
|
||||
export interface PushRoutesConfig {
|
||||
deviceTokenRepository: IDeviceTokenRepository;
|
||||
apnService: ApnService | null;
|
||||
maxBulkRecipients: number;
|
||||
emitEvent?: (event: NotificationsRouteEvent) => void;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Device token table — slimmed migration. Stores APN / FCM / WebPush
|
||||
-- device tokens for push delivery. Upstream stored-helper-functions
|
||||
-- and cross-plugin record refs are deferred until those sibling
|
||||
-- plugins land on genesis (see openspec/changes/add-api-plugin-notifications).
|
||||
|
||||
DEFINE TABLE OVERWRITE device_token SCHEMAFULL;
|
||||
|
||||
DEFINE FIELD OVERWRITE user_id ON device_token TYPE record<user>
|
||||
ASSERT $value != NONE;
|
||||
|
||||
DEFINE FIELD OVERWRITE site ON device_token TYPE option<record<site>>;
|
||||
|
||||
DEFINE FIELD OVERWRITE device_id ON device_token TYPE string
|
||||
ASSERT $value != NONE
|
||||
AND string::len($value) > 0
|
||||
AND string::len($value) <= 255;
|
||||
|
||||
DEFINE FIELD OVERWRITE token ON device_token TYPE string
|
||||
ASSERT $value != NONE
|
||||
AND string::len($value) > 0
|
||||
AND string::len($value) <= 1000;
|
||||
|
||||
DEFINE FIELD OVERWRITE platform ON device_token TYPE string
|
||||
ASSERT $value INSIDE ['ios', 'android', 'web']
|
||||
DEFAULT 'ios';
|
||||
|
||||
DEFINE FIELD OVERWRITE environment ON device_token TYPE string
|
||||
ASSERT $value INSIDE ['production', 'sandbox', 'development']
|
||||
DEFAULT 'sandbox';
|
||||
|
||||
DEFINE FIELD OVERWRITE app_version ON device_token TYPE option<string>;
|
||||
|
||||
DEFINE FIELD OVERWRITE os_version ON device_token TYPE option<string>;
|
||||
|
||||
DEFINE FIELD OVERWRITE is_active ON device_token TYPE bool DEFAULT true;
|
||||
|
||||
DEFINE FIELD OVERWRITE last_used ON device_token TYPE datetime
|
||||
DEFAULT time::now() VALUE time::now();
|
||||
|
||||
DEFINE FIELD OVERWRITE created_at ON device_token TYPE datetime
|
||||
DEFAULT time::now() VALUE $before OR time::now();
|
||||
|
||||
DEFINE FIELD OVERWRITE updated_at ON device_token TYPE datetime
|
||||
DEFAULT time::now() VALUE time::now();
|
||||
|
||||
DEFINE INDEX OVERWRITE device_token_user_idx ON device_token FIELDS user_id;
|
||||
DEFINE INDEX OVERWRITE device_token_token_idx ON device_token FIELDS token UNIQUE;
|
||||
DEFINE INDEX OVERWRITE device_token_active_idx ON device_token FIELDS is_active;
|
||||
DEFINE INDEX OVERWRITE device_token_platform_idx ON device_token FIELDS platform;
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Notification table — slimmed migration covering only the fields the
|
||||
-- @suaveplan/api-plugin-notifications repository actually reads / writes.
|
||||
-- Upstream tables that depend on sibling plugins (fn::notification helpers,
|
||||
-- bitwise types, site/group references) are deferred to follow-up changes.
|
||||
|
||||
DEFINE TABLE OVERWRITE notification SCHEMAFULL;
|
||||
|
||||
DEFINE FIELD OVERWRITE recipient ON notification TYPE record<user>
|
||||
ASSERT $value != NONE;
|
||||
|
||||
DEFINE FIELD OVERWRITE site ON notification TYPE option<record<site>>;
|
||||
|
||||
DEFINE FIELD OVERWRITE actor ON notification TYPE option<record<user>>;
|
||||
|
||||
DEFINE FIELD OVERWRITE type ON notification TYPE string
|
||||
ASSERT $value != NONE;
|
||||
|
||||
DEFINE FIELD OVERWRITE target_id ON notification TYPE option<string>;
|
||||
|
||||
DEFINE FIELD OVERWRITE target_type ON notification TYPE option<string>;
|
||||
|
||||
DEFINE FIELD OVERWRITE content ON notification TYPE string
|
||||
ASSERT $value != NONE;
|
||||
|
||||
DEFINE FIELD OVERWRITE metadata ON notification TYPE option<object> FLEXIBLE
|
||||
DEFAULT {};
|
||||
|
||||
DEFINE FIELD OVERWRITE read ON notification TYPE bool DEFAULT false;
|
||||
|
||||
DEFINE FIELD OVERWRITE dismissed ON notification TYPE bool DEFAULT false;
|
||||
|
||||
DEFINE FIELD OVERWRITE created_at ON notification TYPE datetime
|
||||
DEFAULT time::now() VALUE $value OR time::now();
|
||||
|
||||
DEFINE FIELD OVERWRITE updated_at ON notification TYPE datetime
|
||||
DEFAULT time::now() VALUE time::now();
|
||||
|
||||
DEFINE INDEX OVERWRITE notification_recipient_idx ON notification FIELDS recipient;
|
||||
DEFINE INDEX OVERWRITE notification_created_at_idx ON notification FIELDS created_at;
|
||||
DEFINE INDEX OVERWRITE notification_read_idx ON notification FIELDS read;
|
||||
DEFINE INDEX OVERWRITE notification_type_idx ON notification FIELDS type;
|
||||
@@ -0,0 +1,149 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
|
||||
import { createNotificationsPlugin } from "../../src/plugin/plugin.js";
|
||||
import type { NotificationsPluginConfig } from "../../src/schemas/options.js";
|
||||
|
||||
let fixture: SurrealDBFixture | null = null;
|
||||
let cfg: {
|
||||
url: string;
|
||||
namespace: string;
|
||||
database: string;
|
||||
username: string;
|
||||
password: string;
|
||||
} | null = null;
|
||||
let adapter: SurrealAdapter | null = null;
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
fixture = new SurrealDBFixture();
|
||||
cfg = await fixture.start();
|
||||
adapter = new SurrealAdapter({
|
||||
url: cfg.url,
|
||||
namespace: cfg.namespace,
|
||||
database: cfg.database,
|
||||
username: cfg.username,
|
||||
password: cfg.password,
|
||||
});
|
||||
await adapter.connect();
|
||||
// Apply both migrations manually — the plugin does not own
|
||||
// migration execution.
|
||||
const notif = await readFile(
|
||||
join(__dirname, "..", "..", "surql", "tables", "notification.surql"),
|
||||
"utf8"
|
||||
);
|
||||
const device = await readFile(
|
||||
join(__dirname, "..", "..", "surql", "tables", "device_token.surql"),
|
||||
"utf8"
|
||||
);
|
||||
await adapter.query(notif);
|
||||
await adapter.query(device);
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (adapter !== null) {
|
||||
await adapter.disconnect();
|
||||
adapter = null;
|
||||
}
|
||||
if (fixture !== null) {
|
||||
await fixture.stop();
|
||||
fixture = null;
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
describe("@suaveplan/api-plugin-notifications integration", () => {
|
||||
it("boots, registers device, creates + reads + dismisses + deletes notification", {
|
||||
timeout: 180_000,
|
||||
}, async () => {
|
||||
if (adapter === null) throw new Error("fixture not ready");
|
||||
|
||||
// Seed recipient + site so the record-link constraints pass.
|
||||
await adapter.query(
|
||||
"CREATE user:recipient SET name = 'R'; CREATE site:main SET name = 'S';"
|
||||
);
|
||||
|
||||
const app = new OpenAPIHono();
|
||||
const services = new Map<string, unknown>([
|
||||
["db", adapter],
|
||||
["app", app],
|
||||
]);
|
||||
const plugin = createNotificationsPlugin();
|
||||
const ctx = createPluginContext<NotificationsPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as NotificationsPluginConfig,
|
||||
services,
|
||||
});
|
||||
await plugin.onInit?.(ctx);
|
||||
await plugin.onReady?.(ctx);
|
||||
|
||||
const repo = ctx.getService("notificationRepository");
|
||||
const tokens = ctx.getService("deviceTokenRepository");
|
||||
expect(repo).toBeDefined();
|
||||
expect(tokens).toBeDefined();
|
||||
if (repo === undefined || tokens === undefined) return;
|
||||
|
||||
const created = await repo.create({
|
||||
type: "friend_request",
|
||||
recipient: "user:recipient",
|
||||
site: "site:main",
|
||||
content: "sent you a friend request",
|
||||
});
|
||||
expect(created.id).toBeDefined();
|
||||
|
||||
const fetched = await repo.findById(String(created.id));
|
||||
expect(fetched).not.toBeNull();
|
||||
|
||||
const unread = await repo.getUnreadCount("user:recipient", "site:main");
|
||||
expect(unread).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const read = await repo.markAsRead(String(created.id));
|
||||
expect(read?.read).toBe(true);
|
||||
|
||||
const dismissed = await repo.dismiss(String(created.id));
|
||||
expect(dismissed?.dismissed).toBe(true);
|
||||
|
||||
const list = await repo.findByRecipient("user:recipient", "site:main", {
|
||||
limit: 10,
|
||||
});
|
||||
expect(list.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const deleted = await repo.delete(String(created.id));
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
// Device-token round-trip.
|
||||
const tok = await tokens.register("user:recipient", {
|
||||
deviceToken: `tok-${Date.now()}`,
|
||||
deviceId: "dev1",
|
||||
deviceType: "ios",
|
||||
environment: "sandbox",
|
||||
});
|
||||
expect(tok.platform).toBe("ios");
|
||||
|
||||
const active = await tokens.findActiveForUser("user:recipient");
|
||||
expect(active.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const off = await tokens.deactivate(String(tok.id));
|
||||
expect(off).toBe(true);
|
||||
|
||||
await plugin.onClose?.(ctx);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Test setup for @suaveplan/api-plugin-notifications.
|
||||
*
|
||||
* Installs an in-memory OpenTelemetry MeterProvider so every source
|
||||
* module's meter resolution binds to a real provider, then exports a
|
||||
* `flushTestMetrics()` helper that drains and resets the in-memory
|
||||
* exporter between tests.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics() {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/api-plugin-notifications",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
/^@suaveplan\//,
|
||||
/^@opentelemetry\//,
|
||||
/^@hono\//,
|
||||
/^node:/,
|
||||
"hono",
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 60000,
|
||||
hookTimeout: 180000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user