Initial Commit

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

View File

@@ -0,0 +1,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.

View 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"]
}

View File

@@ -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"]);
});
});

View File

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

View File

@@ -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;
});
});

View File

@@ -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),
};
}
}

View File

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

View File

@@ -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
);
});
});

View File

@@ -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),
};
}
}

View File

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

View File

@@ -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);
});
});

View File

@@ -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();
}
}

View File

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

View File

@@ -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");
});
});

View File

@@ -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);
}

View File

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

View File

@@ -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" });
});
});

View File

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

View File

@@ -0,0 +1,5 @@
export type { NotificationsPluginErrorCode } from "./error-codes.js";
export {
NOTIFICATIONS_PLUGIN_CODES,
NotificationsPluginError,
} from "./error-codes.js";

View 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";

View File

@@ -0,0 +1,5 @@
export {
getNotificationsInstruments,
type NotificationsInstruments,
resetNotificationsInstruments,
} from "./instruments.js";

View File

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

View File

@@ -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);
});
});

View File

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

View File

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

View File

@@ -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);
});
});

View File

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

View File

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

View File

@@ -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);
});
});

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
export { DeviceTokenRepository } from "./device-token-repository.js";
export type {
DeviceTokenRepositoryConfig,
IDeviceTokenRepository,
} from "./device-token-repository.types.js";

View File

@@ -0,0 +1,8 @@
export { NotificationRepository } from "./notification-repository.js";
export type {
BulkOperationResult,
FindNotificationsOptions,
GroupedNotifications,
INotificationRepository,
NotificationRepositoryConfig,
} from "./notification-repository.types.js";

View File

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

View File

@@ -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");
});
});

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
export {
createNotificationsRoutes,
type NotificationsRoutesVariables,
} from "./notifications-routes.js";

View File

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

View File

@@ -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);
});
});

View File

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

View File

@@ -0,0 +1 @@
export { createPushRoutes, type PushRoutesVariables } from "./push-routes.js";

View File

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

View File

@@ -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);
});
});

View File

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

View File

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

View File

@@ -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);
});
});

View File

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

View File

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

View File

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

View File

@@ -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);
});
});

View File

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

View File

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

View File

@@ -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();
});
});

View File

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

View File

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

View File

@@ -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();
});
});

View File

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

View File

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

View File

@@ -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);
});
});

View File

@@ -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);
}

View File

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

View File

@@ -0,0 +1,7 @@
export { ApnService, createApnService } from "./apn-service.js";
export type {
ApnServiceOptions,
PushNotificationFailure,
PushNotificationPayload,
PushNotificationResult,
} from "./apn-service.types.js";

View File

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

View File

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

View File

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

View File

@@ -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);
});
});

View File

@@ -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);
}

View File

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

View File

@@ -0,0 +1,10 @@
export {
createMemoryApnTransport,
MemoryApnTransport,
} from "./apn-transport.js";
export type {
ApnTransport,
ApnTransportFactory,
ApnTransportNotification,
ApnTransportResponse,
} from "./apn-transport.types.js";

View File

@@ -0,0 +1,6 @@
export type {
NotificationsPluginEvents,
NotificationsRouteEvent,
NotificationsRoutesConfig,
PushRoutesConfig,
} from "./types.js";

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
});
});

View File

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

View File

@@ -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"]
}

View File

@@ -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"]
}

View File

@@ -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",
],
},
},
});

View File

@@ -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,
},
});