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