Initial Commit
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
# dispatch-schemas
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the Zod boundary for the `NotificationDispatcher` surface. The
|
||||
dispatcher fans a single call out to whichever delivery channel is
|
||||
selected (APN, email, etc.) — and because that call accepts free-form
|
||||
metadata to drive channel-specific behaviours, every field must be
|
||||
validated at the public edge so the dispatcher never sees a malformed
|
||||
payload. The schemas in this module are the contract every caller must
|
||||
satisfy; invalid input folds into
|
||||
`NotificationsPluginError(DISPATCH_INPUT_INVALID)` carrying the
|
||||
`ZodError` as `cause`.
|
||||
|
||||
## Features
|
||||
|
||||
- `DispatchChannelSchema` — closed enum of the six delivery channels
|
||||
the dispatcher knows about (`email`, `sms`, `push`, `in_app`,
|
||||
`webhook`, `apn`). Matches the
|
||||
`notification-channel`-`NotificationChannel` union plus the
|
||||
in-package `apn` literal.
|
||||
- `DispatchPrioritySchema` — `low`/`normal`/`high`/`urgent`.
|
||||
- `DispatchInputSchema` — every public dispatcher input. Requires
|
||||
`recipient` + `body` to be non-empty; everything else is optional
|
||||
with sensible defaults.
|
||||
- `DispatchResultSchema` — shape returned by `dispatch`. `id` and
|
||||
`messageId` carry the same value (the underlying router id), so
|
||||
callers can pass `result.messageId` straight to
|
||||
`DeliveryEventStore` queries.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { DispatchInputSchema } from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
const input = DispatchInputSchema.parse({
|
||||
recipient: "user@example.com",
|
||||
body: "Welcome!",
|
||||
channel: "email",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
DispatchInputSchema,
|
||||
DispatchResultSchema,
|
||||
} from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
// Pre-validate at the route boundary so the dispatcher never sees a
|
||||
// malformed payload.
|
||||
const parsed = DispatchInputSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return c.json({ error: "DISPATCH_INPUT_INVALID" }, 400);
|
||||
}
|
||||
|
||||
const result = await dispatcher.dispatch({
|
||||
...parsed.data,
|
||||
metadata: { deviceTokens: parsed.data.metadata?.deviceTokens ?? [] },
|
||||
idempotencyKey: req.headers.get("idempotency-key") ?? undefined,
|
||||
});
|
||||
|
||||
// `result` is guaranteed by `DispatchResultSchema`.
|
||||
DispatchResultSchema.parse(result);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DispatchChannelSchema`: `ZodEnum<["email","sms","push","in_app","webhook","apn"]>`
|
||||
- `DispatchPrioritySchema`: `ZodEnum<["low","normal","high","urgent"]>`
|
||||
- `DispatchInputSchema`: `ZodObject<{ recipient, body, subject?, channel?, metadata?, priority?, idempotencyKey? }>`
|
||||
- `DispatchResultSchema`: `ZodObject<{ id, messageId, channel, status, sentAt? }>`
|
||||
- Types: `DispatchChannel`, `DispatchPriority`, `DispatchInput`, `DispatchResult`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The schemas live next to the existing notification/push schemas (one
|
||||
`src/schemas/*.ts` per surface) so the public boundary is a single
|
||||
import path.
|
||||
- `metadata` is `Record<string, unknown>` — channels narrow it
|
||||
internally with their own type guards (e.g. the APN channel checks
|
||||
for `deviceToken: string` or `deviceTokens: string[]`).
|
||||
- `DispatchResult.status` is intentionally narrow (`sent`/`delivered`)
|
||||
because the dispatcher rejects failures by throwing
|
||||
`DISPATCH_FAILED`; a `failed` status would be dead data.
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DispatchInputSchema,
|
||||
DispatchResultSchema,
|
||||
} from "./dispatch-schemas.js";
|
||||
|
||||
describe("DispatchInputSchema", () => {
|
||||
it("DispatchInputSchema rejects empty recipient", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = DispatchInputSchema.safeParse({ recipient: "", body: "x" });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("DispatchInputSchema rejects empty body", { timeout: 5000 }, () => {
|
||||
const r = DispatchInputSchema.safeParse({ recipient: "u", body: "" });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("DispatchInputSchema accepts a minimal payload", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = DispatchInputSchema.safeParse({
|
||||
recipient: "u",
|
||||
body: "hi",
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("DispatchInputSchema accepts idempotencyKey", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = DispatchInputSchema.safeParse({
|
||||
recipient: "u",
|
||||
body: "hi",
|
||||
idempotencyKey: "abc",
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown channel literal", { timeout: 5000 }, () => {
|
||||
const r = DispatchInputSchema.safeParse({
|
||||
recipient: "u",
|
||||
body: "hi",
|
||||
channel: "carrier-pigeon",
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown priority literal", { timeout: 5000 }, () => {
|
||||
const r = DispatchInputSchema.safeParse({
|
||||
recipient: "u",
|
||||
body: "hi",
|
||||
priority: "ludicrous",
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty idempotencyKey", { timeout: 5000 }, () => {
|
||||
const r = DispatchInputSchema.safeParse({
|
||||
recipient: "u",
|
||||
body: "hi",
|
||||
idempotencyKey: "",
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DispatchResultSchema", () => {
|
||||
it("DispatchResultSchema accepts canonical shape", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = DispatchResultSchema.safeParse({
|
||||
id: "notif_1",
|
||||
messageId: "notif_1",
|
||||
channel: "apn",
|
||||
status: "sent",
|
||||
sentAt: "2026-05-19T00:00:00.000Z",
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects missing id", { timeout: 5000 }, () => {
|
||||
const r = DispatchResultSchema.safeParse({
|
||||
messageId: "x",
|
||||
channel: "apn",
|
||||
status: "sent",
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Channel literals accepted at the dispatcher boundary. Mirrors the
|
||||
* `NotificationChannel` union from `@suaveplan/notification-channel`
|
||||
* plus the in-package `apn` channel name.
|
||||
*/
|
||||
export const DispatchChannelSchema = z.enum([
|
||||
"email",
|
||||
"sms",
|
||||
"push",
|
||||
"in_app",
|
||||
"webhook",
|
||||
"apn",
|
||||
]);
|
||||
export type DispatchChannel = z.infer<typeof DispatchChannelSchema>;
|
||||
|
||||
/**
|
||||
* Priority literals carried through to the underlying
|
||||
* `NotificationRouter`. Identical wire-shape to upstream's
|
||||
* `NotificationPriority`.
|
||||
*/
|
||||
export const DispatchPrioritySchema = z.enum([
|
||||
"low",
|
||||
"normal",
|
||||
"high",
|
||||
"urgent",
|
||||
]);
|
||||
export type DispatchPriority = z.infer<typeof DispatchPrioritySchema>;
|
||||
|
||||
/**
|
||||
* Boundary schema for a single dispatcher call.
|
||||
*
|
||||
* The dispatcher Zod-validates every public surface before any state
|
||||
* mutates. Invalid input is folded into
|
||||
* `NotificationsPluginError(DISPATCH_INPUT_INVALID)`.
|
||||
*/
|
||||
export const DispatchInputSchema = z.object({
|
||||
/** Logical recipient — depends on channel (email address, phone, deviceToken-bag). */
|
||||
recipient: z.string().min(1, "recipient must be non-empty"),
|
||||
/** Notification body. Channels render this as text by default. */
|
||||
body: z.string().min(1, "body must be non-empty"),
|
||||
/** Optional subject line (email channel + push alert title fallback). */
|
||||
subject: z.string().optional(),
|
||||
/**
|
||||
* Explicit channel selection. When omitted, the dispatcher falls
|
||||
* back to its configured `defaultChannel`.
|
||||
*/
|
||||
channel: DispatchChannelSchema.optional(),
|
||||
/**
|
||||
* Channel-specific metadata. The APN channel reads `deviceToken`
|
||||
* or `deviceTokens` from here; the email channel reads `html`.
|
||||
*/
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
/** Priority hint forwarded to the router. */
|
||||
priority: DispatchPrioritySchema.optional(),
|
||||
/**
|
||||
* Idempotency key. When present AND a non-noop adapter is
|
||||
* configured, the dispatcher routes the call through
|
||||
* `adapter.execute(key, …)` to dedup duplicates.
|
||||
*/
|
||||
idempotencyKey: z.string().min(1).optional(),
|
||||
});
|
||||
export type DispatchInput = z.infer<typeof DispatchInputSchema>;
|
||||
|
||||
/**
|
||||
* Shape returned by `NotificationDispatcher.dispatch`. The id is
|
||||
* surfaced from the underlying router (`notif_<n>`); `messageId`
|
||||
* carries the same value to align with the `DeliveryEvent.messageId`
|
||||
* field consumed by `@suaveplan/delivery-tracking`.
|
||||
*/
|
||||
export const DispatchResultSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
messageId: z.string().min(1),
|
||||
channel: DispatchChannelSchema,
|
||||
status: z.enum(["sent", "delivered"]),
|
||||
sentAt: z.string().optional(),
|
||||
});
|
||||
export type DispatchResult = z.infer<typeof DispatchResultSchema>;
|
||||
@@ -0,0 +1,59 @@
|
||||
export {
|
||||
type BulkOperationResponse,
|
||||
BulkOperationResponseSchema,
|
||||
type CreateNotification,
|
||||
CreateNotificationSchema,
|
||||
type GroupedNotifications,
|
||||
GroupedNotificationsSchema,
|
||||
type MarkManyAsRead,
|
||||
MarkManyAsReadSchema,
|
||||
type Notification,
|
||||
type NotificationIdParam,
|
||||
NotificationIdParamSchema,
|
||||
type NotificationListResponse,
|
||||
NotificationListResponseSchema,
|
||||
type NotificationMetadata,
|
||||
NotificationMetadataSchema,
|
||||
type NotificationQuery,
|
||||
NotificationQuerySchema,
|
||||
NotificationSchema,
|
||||
type NotificationType,
|
||||
NotificationTypeSchema,
|
||||
type TargetType,
|
||||
TargetTypeSchema,
|
||||
type UnreadCountResponse,
|
||||
UnreadCountResponseSchema,
|
||||
type UpdateNotification,
|
||||
UpdateNotificationSchema,
|
||||
} from "./notification-schemas.js";
|
||||
export {
|
||||
type ApnConfig,
|
||||
ApnConfigSchema,
|
||||
type NotificationsPluginConfig,
|
||||
type NotificationsPluginConfigInput,
|
||||
NotificationsPluginOptionsSchema,
|
||||
} from "./options.js";
|
||||
export {
|
||||
type DeviceEnvironment,
|
||||
DeviceEnvironmentSchema,
|
||||
type DevicePlatform,
|
||||
DevicePlatformSchema,
|
||||
type DeviceToken,
|
||||
type DeviceTokenIdParam,
|
||||
DeviceTokenIdParamSchema,
|
||||
DeviceTokenSchema,
|
||||
type RegisterDeviceToken,
|
||||
type RegisterDeviceTokenResponse,
|
||||
RegisterDeviceTokenResponseSchema,
|
||||
RegisterDeviceTokenSchema,
|
||||
type SendBulkPushNotification,
|
||||
SendBulkPushNotificationSchema,
|
||||
type SendBulkPushResponse,
|
||||
SendBulkPushResponseSchema,
|
||||
type SendPushNotification,
|
||||
SendPushNotificationSchema,
|
||||
type SendPushResponse,
|
||||
SendPushResponseSchema,
|
||||
type UnregisterDeviceTokenResponse,
|
||||
UnregisterDeviceTokenResponseSchema,
|
||||
} from "./push-schemas.js";
|
||||
@@ -0,0 +1,95 @@
|
||||
# `schemas/notification-schemas`
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod boundary schemas for the notification entity and every wire surface
|
||||
the plugin exposes — list / get / create / update / mark-read /
|
||||
mark-many-as-read / dismiss / delete / unread-count / grouped. The
|
||||
schemas live inside the plugin because the suaveplan
|
||||
`@suaveplan/api-schemas` package does not yet ship a `notification`
|
||||
subpath; co-locating keeps the plugin self-contained until the upstream
|
||||
package is augmented.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod 4 default-import style (`import z from "zod"`) per CLAUDE.md.
|
||||
- Twelve canonical notification types and six target types, both
|
||||
pinned by `z.enum(...)`.
|
||||
- Passthrough `NotificationMetadataSchema` so callers may attach
|
||||
domain-specific fields without forking the schema.
|
||||
- Query schema with `z.coerce.number()` / `z.coerce.boolean()` so
|
||||
query-string values arriving as strings parse cleanly.
|
||||
- Response wrappers (`NotificationListResponseSchema`,
|
||||
`UnreadCountResponseSchema`, `GroupedNotificationsSchema`,
|
||||
`BulkOperationResponseSchema`) used by `createNotificationRoutes`.
|
||||
- Param schema for `/:id` path captures (`NotificationIdParamSchema`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CreateNotificationSchema } from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
const data = CreateNotificationSchema.parse({
|
||||
type: "friend_request",
|
||||
recipient: "user:abc",
|
||||
content: "sent you a friend request",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Compose with `@hono/zod-openapi`'s `createRoute` to produce an OpenAPI
|
||||
schema and request validator in one step:
|
||||
|
||||
```ts
|
||||
import { createRoute } from "@hono/zod-openapi";
|
||||
import {
|
||||
CreateNotificationSchema,
|
||||
NotificationSchema,
|
||||
} from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
createRoute({
|
||||
method: "post",
|
||||
path: "/",
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": { schema: CreateNotificationSchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: "Created",
|
||||
content: {
|
||||
"application/json": { schema: NotificationSchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NotificationTypeSchema` / `NotificationType`
|
||||
- `TargetTypeSchema` / `TargetType`
|
||||
- `NotificationMetadataSchema` / `NotificationMetadata`
|
||||
- `NotificationSchema` / `Notification`
|
||||
- `CreateNotificationSchema` / `CreateNotification`
|
||||
- `UpdateNotificationSchema` / `UpdateNotification`
|
||||
- `NotificationQuerySchema` / `NotificationQuery`
|
||||
- `NotificationListResponseSchema` / `NotificationListResponse`
|
||||
- `UnreadCountResponseSchema` / `UnreadCountResponse`
|
||||
- `GroupedNotificationsSchema` / `GroupedNotifications`
|
||||
- `MarkManyAsReadSchema` / `MarkManyAsRead`
|
||||
- `NotificationIdParamSchema` / `NotificationIdParam`
|
||||
- `BulkOperationResponseSchema` / `BulkOperationResponse`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`recipient`, `actor`, `target_id`, and `site` are typed as `z.string()`
|
||||
rather than tightening to a SurrealDB record-id regex because tests and
|
||||
upstream callers pass both bare ids (`abc`) and prefixed ids
|
||||
(`user:abc`); the repository normalises on the way down. The schema
|
||||
deliberately accepts both forms at the boundary so handlers can stay
|
||||
permissive while persistence remains strict.
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
CreateNotificationSchema,
|
||||
MarkManyAsReadSchema,
|
||||
NotificationIdParamSchema,
|
||||
NotificationListResponseSchema,
|
||||
NotificationQuerySchema,
|
||||
NotificationSchema,
|
||||
NotificationTypeSchema,
|
||||
TargetTypeSchema,
|
||||
UpdateNotificationSchema,
|
||||
} from "./notification-schemas.js";
|
||||
|
||||
describe("NotificationTypeSchema", () => {
|
||||
it("accepts canonical types", { timeout: 5000 }, () => {
|
||||
expect(NotificationTypeSchema.parse("friend_request")).toBe(
|
||||
"friend_request"
|
||||
);
|
||||
expect(NotificationTypeSchema.parse("message")).toBe("message");
|
||||
});
|
||||
it("rejects unknown types", { timeout: 5000 }, () => {
|
||||
expect(() => NotificationTypeSchema.parse("bogus")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TargetTypeSchema", () => {
|
||||
it("accepts canonical targets", { timeout: 5000 }, () => {
|
||||
expect(TargetTypeSchema.parse("post")).toBe("post");
|
||||
});
|
||||
it("rejects unknown targets", { timeout: 5000 }, () => {
|
||||
expect(() => TargetTypeSchema.parse("planet")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateNotificationSchema", () => {
|
||||
it("requires type, recipient, content", { timeout: 5000 }, () => {
|
||||
const ok = CreateNotificationSchema.parse({
|
||||
type: "friend_request",
|
||||
recipient: "user:1",
|
||||
content: "hi",
|
||||
});
|
||||
expect(ok.read).toBe(false);
|
||||
expect(ok.dismissed).toBe(false);
|
||||
});
|
||||
it("rejects missing content", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
CreateNotificationSchema.parse({
|
||||
type: "friend_request",
|
||||
recipient: "user:1",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
it("accepts metadata passthrough", { timeout: 5000 }, () => {
|
||||
const v = CreateNotificationSchema.parse({
|
||||
type: "post_like",
|
||||
recipient: "user:1",
|
||||
content: "liked",
|
||||
metadata: { title: "T", custom_field: "X" },
|
||||
});
|
||||
expect((v.metadata as Record<string, unknown>).custom_field).toBe("X");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpdateNotificationSchema", () => {
|
||||
it("accepts a partial body", { timeout: 5000 }, () => {
|
||||
const v = UpdateNotificationSchema.parse({ read: true });
|
||||
expect(v.read).toBe(true);
|
||||
});
|
||||
it("accepts an empty body", { timeout: 5000 }, () => {
|
||||
const v = UpdateNotificationSchema.parse({});
|
||||
expect(v).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationQuerySchema", () => {
|
||||
it("coerces string query params", { timeout: 5000 }, () => {
|
||||
const v = NotificationQuerySchema.parse({
|
||||
limit: "30",
|
||||
offset: "5",
|
||||
unreadOnly: "true",
|
||||
});
|
||||
expect(v.limit).toBe(30);
|
||||
expect(v.offset).toBe(5);
|
||||
expect(v.unreadOnly).toBe(true);
|
||||
});
|
||||
it("rejects limit > 100", { timeout: 5000 }, () => {
|
||||
expect(() => NotificationQuerySchema.parse({ limit: "500" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationListResponseSchema", () => {
|
||||
it("validates a response envelope", { timeout: 5000 }, () => {
|
||||
const v = NotificationListResponseSchema.parse({
|
||||
data: [],
|
||||
total: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
expect(v.total).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MarkManyAsReadSchema", () => {
|
||||
it("requires at least one id", { timeout: 5000 }, () => {
|
||||
expect(() => MarkManyAsReadSchema.parse({ ids: [] })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationIdParamSchema", () => {
|
||||
it("rejects empty id", { timeout: 5000 }, () => {
|
||||
expect(() => NotificationIdParamSchema.parse({ id: "" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationSchema", () => {
|
||||
it("parses a complete row", { timeout: 5000 }, () => {
|
||||
const v = NotificationSchema.parse({
|
||||
id: "notification:1",
|
||||
type: "friend_request",
|
||||
recipient: "user:1",
|
||||
content: "hi",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
expect(v.read).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
// schema: boundary-only
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Notification entity / wire schemas owned locally by the plugin.
|
||||
*
|
||||
* `@suaveplan/api-schemas` on genesis does not yet ship a `notification`
|
||||
* subpath, so the plugin owns the boundary schemas the same way
|
||||
* `@suaveplan/api-plugin-events` owns its event-entity schemas. See
|
||||
* `openspec/changes/add-api-plugin-notifications/proposal.md` for the
|
||||
* rationale and the eventual upstream-merge path.
|
||||
*/
|
||||
|
||||
export const NotificationTypeSchema = z.enum([
|
||||
"friend_request",
|
||||
"friend_accepted",
|
||||
"post_like",
|
||||
"post_comment",
|
||||
"post_mention",
|
||||
"group_invite",
|
||||
"group_join",
|
||||
"group_mention",
|
||||
"system",
|
||||
"achievement",
|
||||
"follow",
|
||||
"message",
|
||||
]);
|
||||
export type NotificationType = z.infer<typeof NotificationTypeSchema>;
|
||||
|
||||
export const TargetTypeSchema = z.enum([
|
||||
"user",
|
||||
"post",
|
||||
"comment",
|
||||
"group",
|
||||
"achievement",
|
||||
"system",
|
||||
]);
|
||||
export type TargetType = z.infer<typeof TargetTypeSchema>;
|
||||
|
||||
export const NotificationMetadataSchema = z
|
||||
.object({
|
||||
title: z.string().optional(),
|
||||
body: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
action_type: z.number().optional(),
|
||||
action_target: z.string().optional(),
|
||||
imageUrl: z.string().url().optional(),
|
||||
url: z.string().url().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
export type NotificationMetadata = z.infer<typeof NotificationMetadataSchema>;
|
||||
|
||||
export const NotificationSchema = z.object({
|
||||
id: z.string(),
|
||||
type: NotificationTypeSchema,
|
||||
recipient: z.string().describe("User receiving the notification"),
|
||||
actor: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("User who triggered the notification"),
|
||||
target_id: z.string().optional(),
|
||||
target_type: TargetTypeSchema.optional(),
|
||||
site: z.string().optional(),
|
||||
content: z.string(),
|
||||
metadata: NotificationMetadataSchema.optional(),
|
||||
read: z.boolean().default(false),
|
||||
dismissed: z.boolean().default(false),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
});
|
||||
export type Notification = z.infer<typeof NotificationSchema>;
|
||||
|
||||
export const CreateNotificationSchema = z.object({
|
||||
type: NotificationTypeSchema,
|
||||
recipient: z.string(),
|
||||
actor: z.string().optional(),
|
||||
target_id: z.string().optional(),
|
||||
target_type: TargetTypeSchema.optional(),
|
||||
site: z.string().optional(),
|
||||
content: z.string().min(1),
|
||||
metadata: NotificationMetadataSchema.optional(),
|
||||
read: z.boolean().optional().default(false),
|
||||
dismissed: z.boolean().optional().default(false),
|
||||
});
|
||||
export type CreateNotification = z.infer<typeof CreateNotificationSchema>;
|
||||
|
||||
export const UpdateNotificationSchema = z.object({
|
||||
read: z.boolean().optional(),
|
||||
dismissed: z.boolean().optional(),
|
||||
metadata: NotificationMetadataSchema.optional(),
|
||||
});
|
||||
export type UpdateNotification = z.infer<typeof UpdateNotificationSchema>;
|
||||
|
||||
export const NotificationQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
unreadOnly: z.coerce.boolean().optional().default(false),
|
||||
type: NotificationTypeSchema.optional(),
|
||||
since: z.string().datetime().optional(),
|
||||
});
|
||||
export type NotificationQuery = z.infer<typeof NotificationQuerySchema>;
|
||||
|
||||
export const NotificationListResponseSchema = z.object({
|
||||
data: z.array(NotificationSchema),
|
||||
total: z.number().int(),
|
||||
hasMore: z.boolean().optional(),
|
||||
});
|
||||
export type NotificationListResponse = z.infer<
|
||||
typeof NotificationListResponseSchema
|
||||
>;
|
||||
|
||||
export const UnreadCountResponseSchema = z.object({
|
||||
count: z.number().int().min(0),
|
||||
});
|
||||
export type UnreadCountResponse = z.infer<typeof UnreadCountResponseSchema>;
|
||||
|
||||
export const GroupedNotificationsSchema = z.record(
|
||||
z.string(),
|
||||
z.array(NotificationSchema)
|
||||
);
|
||||
export type GroupedNotifications = z.infer<typeof GroupedNotificationsSchema>;
|
||||
|
||||
export const MarkManyAsReadSchema = z.object({
|
||||
ids: z.array(z.string()).min(1),
|
||||
});
|
||||
export type MarkManyAsRead = z.infer<typeof MarkManyAsReadSchema>;
|
||||
|
||||
export const NotificationIdParamSchema = z.object({ id: z.string().min(1) });
|
||||
export type NotificationIdParam = z.infer<typeof NotificationIdParamSchema>;
|
||||
|
||||
export const BulkOperationResponseSchema = z.object({
|
||||
updated: z.number().int().min(0),
|
||||
});
|
||||
export type BulkOperationResponse = z.infer<typeof BulkOperationResponseSchema>;
|
||||
@@ -0,0 +1,73 @@
|
||||
# `schemas/options`
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schema for plugin options accepted by `createNotificationsPlugin()`
|
||||
plus the nested APN configuration shape (`ApnConfigSchema`). Every
|
||||
field carries a default so consumers can call the factory with no
|
||||
arguments and still receive a fully-resolved
|
||||
`NotificationsPluginConfig`. Invalid input surfaces a single
|
||||
`NotificationsPluginError` with `code === INVALID_CONFIG` and the
|
||||
`ZodError` as `cause`.
|
||||
|
||||
## Features
|
||||
|
||||
- `basePath` defaults to `/notifications`; `pushBasePath` defaults to
|
||||
`/push` — both mount independently so hosts can split the surface
|
||||
behind different auth gates.
|
||||
- `enablePush` flag gates the APN service registration. When the flag
|
||||
is `false` the plugin still mounts `/notifications` but the
|
||||
`/push/*` routes return `503` for delivery endpoints.
|
||||
- `apn` accepts either certificate / key / pfx pairs or a token
|
||||
bundle (`{ key, keyId, teamId }`). Boundary validation happens here;
|
||||
cross-field validation (exactly-one-auth-method) happens later in
|
||||
the APN service.
|
||||
- `autoCleanOldDays` controls the optional retention job (`0` disables
|
||||
cleanup); `maxBulkRecipients` caps the size of `POST /push/send-bulk`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createNotificationsPlugin } from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
const plugin = createNotificationsPlugin({
|
||||
basePath: "/v1/notifications",
|
||||
enablePush: true,
|
||||
apn: {
|
||||
token: { key: "/keys/AuthKey_ABC.p8", keyId: "ABC", teamId: "TEAM" },
|
||||
defaultTopic: "com.example.app",
|
||||
production: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
`z.input<typeof NotificationsPluginOptionsSchema>` is exported as
|
||||
`NotificationsPluginConfigInput` so callers can use it to type their
|
||||
own deployment-config files:
|
||||
|
||||
```ts
|
||||
import type { NotificationsPluginConfigInput } from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
interface DeploymentConfig {
|
||||
plugins: { notifications: NotificationsPluginConfigInput };
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ApnConfigSchema` / `ApnConfig`
|
||||
- `NotificationsPluginOptionsSchema`
|
||||
- `NotificationsPluginConfig` — `z.infer<...>`
|
||||
- `NotificationsPluginConfigInput` — `z.input<...>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema does not enforce "exactly one of cert/key/pfx/token" at the
|
||||
boundary because some deployments pass `passphrase` alone (when the
|
||||
key file already lives on disk and is referenced by path). The APN
|
||||
service's `validateConfig()` method enforces the cross-field rule and
|
||||
throws `APN_CONFIG_MISSING` / `APN_CONFIG_INVALID` at
|
||||
`initialize()` time so the error surfaces with a stable code and full
|
||||
telemetry context.
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
ApnConfigSchema,
|
||||
NotificationsPluginOptionsSchema,
|
||||
} from "./options.js";
|
||||
|
||||
describe("NotificationsPluginOptionsSchema", () => {
|
||||
it("applies defaults on empty input", { timeout: 5000 }, () => {
|
||||
const v = NotificationsPluginOptionsSchema.parse({});
|
||||
expect(v.basePath).toBe("/notifications");
|
||||
expect(v.pushBasePath).toBe("/push");
|
||||
expect(v.enablePush).toBe(false);
|
||||
expect(v.autoCleanOldDays).toBe(30);
|
||||
expect(v.maxBulkRecipients).toBe(1000);
|
||||
expect(v.enableDispatcher).toBe(false);
|
||||
expect(v.defaultChannel).toBe("apn");
|
||||
expect(v.deliveryTenantId).toBe("default");
|
||||
});
|
||||
|
||||
it("accepts enableDispatcher + defaultChannel + tenantId", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const v = NotificationsPluginOptionsSchema.parse({
|
||||
enableDispatcher: true,
|
||||
defaultChannel: "email",
|
||||
deliveryTenantId: "tenant-a",
|
||||
});
|
||||
expect(v.enableDispatcher).toBe(true);
|
||||
expect(v.defaultChannel).toBe("email");
|
||||
expect(v.deliveryTenantId).toBe("tenant-a");
|
||||
});
|
||||
|
||||
it("rejects unknown defaultChannel literal", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
NotificationsPluginOptionsSchema.parse({
|
||||
defaultChannel: "carrier-pigeon",
|
||||
} as never)
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty deliveryTenantId", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
NotificationsPluginOptionsSchema.parse({ deliveryTenantId: "" })
|
||||
).toThrow();
|
||||
});
|
||||
it("rejects empty basePath", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
NotificationsPluginOptionsSchema.parse({ basePath: "" })
|
||||
).toThrow();
|
||||
});
|
||||
it("rejects negative autoCleanOldDays", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
NotificationsPluginOptionsSchema.parse({ autoCleanOldDays: -1 })
|
||||
).toThrow();
|
||||
});
|
||||
it("rejects maxBulkRecipients > 10000", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
NotificationsPluginOptionsSchema.parse({
|
||||
maxBulkRecipients: 20000,
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
it("accepts a valid APN config", { timeout: 5000 }, () => {
|
||||
const v = NotificationsPluginOptionsSchema.parse({
|
||||
enablePush: true,
|
||||
apn: {
|
||||
token: { key: "k", keyId: "i", teamId: "t" },
|
||||
production: true,
|
||||
},
|
||||
});
|
||||
expect(v.apn?.production).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApnConfigSchema", () => {
|
||||
it("defaults production to false", { timeout: 5000 }, () => {
|
||||
const v = ApnConfigSchema.parse({});
|
||||
expect(v.production).toBe(false);
|
||||
});
|
||||
it("rejects partial token (missing teamId)", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
ApnConfigSchema.parse({
|
||||
token: { key: "k", keyId: "i", teamId: "" },
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import z from "zod";
|
||||
|
||||
/** Channel literals from `@suaveplan/notification-channel`, mirrored here so Zod can validate. */
|
||||
const DispatcherChannelEnum = z.enum([
|
||||
"email",
|
||||
"sms",
|
||||
"push",
|
||||
"in_app",
|
||||
"webhook",
|
||||
"apn",
|
||||
]);
|
||||
|
||||
/**
|
||||
* APN configuration accepted at plugin-construction time. Mirrors the
|
||||
* upstream `APNConfig` surface but is owned by the plugin's Zod
|
||||
* boundary so the values arrive at the transport already validated.
|
||||
*/
|
||||
export const ApnConfigSchema = z.object({
|
||||
cert: z.string().optional(),
|
||||
key: z.string().optional(),
|
||||
pfx: z.string().optional(),
|
||||
passphrase: z.string().optional(),
|
||||
token: z
|
||||
.object({
|
||||
key: z.string().min(1),
|
||||
keyId: z.string().min(1),
|
||||
teamId: z.string().min(1),
|
||||
})
|
||||
.optional(),
|
||||
production: z.boolean().default(false),
|
||||
defaultTopic: z.string().optional(),
|
||||
address: z.string().optional(),
|
||||
port: z.number().int().positive().optional(),
|
||||
connectionRetryLimit: z.number().int().min(0).optional(),
|
||||
requestTimeout: z.number().int().positive().optional(),
|
||||
rejectUnauthorized: z.boolean().optional(),
|
||||
});
|
||||
export type ApnConfig = z.infer<typeof ApnConfigSchema>;
|
||||
|
||||
/**
|
||||
* Plugin options accepted by `createNotificationsPlugin`.
|
||||
*
|
||||
* Every field has a default so a no-arg call
|
||||
* (`createNotificationsPlugin()`) produces a fully-resolved
|
||||
* `NotificationsPluginConfig`.
|
||||
*/
|
||||
export const NotificationsPluginOptionsSchema = z.object({
|
||||
/** Mount prefix for the `/notifications` route group. */
|
||||
basePath: z.string().min(1).default("/notifications"),
|
||||
/** Mount prefix for the `/push` route group. */
|
||||
pushBasePath: z.string().min(1).default("/push"),
|
||||
/** Enable APN push delivery (registers the APN service). */
|
||||
enablePush: z.boolean().default(false),
|
||||
/** APN-specific config; required only when `enablePush === true`. */
|
||||
apn: ApnConfigSchema.optional(),
|
||||
/** Auto-clean read notifications older than N days (0 disables). */
|
||||
autoCleanOldDays: z.number().int().min(0).default(30),
|
||||
/** Maximum recipients accepted by `POST /push/send-bulk`. */
|
||||
maxBulkRecipients: z.number().int().min(1).max(10000).default(1000),
|
||||
/**
|
||||
* Opt in to constructing a `NotificationDispatcher` that fans
|
||||
* notifications across `notification-channel`-style delivery
|
||||
* channels. Off by default — existing routes still call
|
||||
* `apnService` directly.
|
||||
*/
|
||||
enableDispatcher: z.boolean().default(false),
|
||||
/**
|
||||
* Default delivery channel used by the dispatcher when an input
|
||||
* does not specify one. Only consulted when `enableDispatcher` is
|
||||
* on.
|
||||
*/
|
||||
defaultChannel: DispatcherChannelEnum.default("apn"),
|
||||
/**
|
||||
* Tenant id stamped on every `DeliveryEvent` emitted by the
|
||||
* dispatcher. Required to make the events queryable per-tenant.
|
||||
*/
|
||||
deliveryTenantId: z.string().min(1).default("default"),
|
||||
});
|
||||
|
||||
/** Resolved config used by the plugin runtime. */
|
||||
export type NotificationsPluginConfig = z.infer<
|
||||
typeof NotificationsPluginOptionsSchema
|
||||
>;
|
||||
|
||||
/** Caller-facing config — every field optional. */
|
||||
export type NotificationsPluginConfigInput = z.input<
|
||||
typeof NotificationsPluginOptionsSchema
|
||||
>;
|
||||
@@ -0,0 +1,97 @@
|
||||
# `schemas/push-schemas`
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod boundary schemas for the `/push/*` route group and the
|
||||
device-token entity stored in SurrealDB. Schemas describe both the wire
|
||||
surface (register / unregister / send / send-bulk requests and
|
||||
responses) and the persisted record shape that
|
||||
`DeviceTokenRepository` reads and writes.
|
||||
|
||||
## Features
|
||||
|
||||
- Three constrained enums (`DevicePlatformSchema`,
|
||||
`DeviceEnvironmentSchema`) so platform and environment can only be
|
||||
one of the documented literals.
|
||||
- Defaults on optional fields (`deviceType` defaults to `"ios"`,
|
||||
`environment` defaults to `"sandbox"`) so callers can pass minimal
|
||||
payloads.
|
||||
- Hard bounds on payload sizes (`title` ≤ 200, `body` ≤ 2000,
|
||||
`userIds.length` ≤ 1000) so the routes reject pathological input at
|
||||
the boundary before any database work happens.
|
||||
- Response envelopes that match the route handler's serialisation:
|
||||
every push response carries `success`, `sent`, `failed`, and (for
|
||||
the bulk variant) `totalUsers`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
RegisterDeviceTokenSchema,
|
||||
SendPushNotificationSchema,
|
||||
} from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
const reg = RegisterDeviceTokenSchema.parse({ deviceToken: "abc123" });
|
||||
const send = SendPushNotificationSchema.parse({
|
||||
userId: "user:42",
|
||||
title: "Hi",
|
||||
body: "Welcome",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Compose with `@hono/zod-openapi` `createRoute` to generate OpenAPI
|
||||
operation schemas and validators for the `/push/*` group:
|
||||
|
||||
```ts
|
||||
import { createRoute } from "@hono/zod-openapi";
|
||||
import {
|
||||
SendBulkPushNotificationSchema,
|
||||
SendBulkPushResponseSchema,
|
||||
} from "@suaveplan/api-plugin-notifications";
|
||||
|
||||
createRoute({
|
||||
method: "post",
|
||||
path: "/send-bulk",
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: SendBulkPushNotificationSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Bulk send result",
|
||||
content: {
|
||||
"application/json": { schema: SendBulkPushResponseSchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DevicePlatformSchema` / `DevicePlatform`
|
||||
- `DeviceEnvironmentSchema` / `DeviceEnvironment`
|
||||
- `DeviceTokenSchema` / `DeviceToken`
|
||||
- `RegisterDeviceTokenSchema` / `RegisterDeviceToken`
|
||||
- `RegisterDeviceTokenResponseSchema` / `RegisterDeviceTokenResponse`
|
||||
- `UnregisterDeviceTokenResponseSchema` / `UnregisterDeviceTokenResponse`
|
||||
- `SendPushNotificationSchema` / `SendPushNotification`
|
||||
- `SendBulkPushNotificationSchema` / `SendBulkPushNotification`
|
||||
- `SendPushResponseSchema` / `SendPushResponse`
|
||||
- `SendBulkPushResponseSchema` / `SendBulkPushResponse`
|
||||
- `DeviceTokenIdParamSchema` / `DeviceTokenIdParam`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The wire-side `deviceToken` field is named with the leading lower-camel
|
||||
prefix even though the database column is `token`; the route handler
|
||||
maps between the two. This matches the upstream public surface and
|
||||
keeps mobile-client payloads readable without forcing every iOS / FCM
|
||||
client to rename its outbound key.
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DeviceTokenIdParamSchema,
|
||||
DeviceTokenSchema,
|
||||
RegisterDeviceTokenSchema,
|
||||
SendBulkPushNotificationSchema,
|
||||
SendPushNotificationSchema,
|
||||
} from "./push-schemas.js";
|
||||
|
||||
describe("RegisterDeviceTokenSchema", () => {
|
||||
it("applies default deviceType / environment", { timeout: 5000 }, () => {
|
||||
const v = RegisterDeviceTokenSchema.parse({ deviceToken: "abc" });
|
||||
expect(v.deviceType).toBe("ios");
|
||||
expect(v.environment).toBe("sandbox");
|
||||
});
|
||||
it("rejects empty deviceToken", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
RegisterDeviceTokenSchema.parse({ deviceToken: "" })
|
||||
).toThrow();
|
||||
});
|
||||
it("rejects too-long deviceToken", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
RegisterDeviceTokenSchema.parse({
|
||||
deviceToken: "x".repeat(1001),
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
it("rejects invalid deviceType", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
RegisterDeviceTokenSchema.parse({
|
||||
deviceToken: "abc",
|
||||
deviceType: "windows" as never,
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SendPushNotificationSchema", () => {
|
||||
it("requires userId/title/body", { timeout: 5000 }, () => {
|
||||
const v = SendPushNotificationSchema.parse({
|
||||
userId: "user:1",
|
||||
title: "T",
|
||||
body: "B",
|
||||
});
|
||||
expect(v.userId).toBe("user:1");
|
||||
});
|
||||
it("rejects empty title", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
SendPushNotificationSchema.parse({
|
||||
userId: "user:1",
|
||||
title: "",
|
||||
body: "B",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SendBulkPushNotificationSchema", () => {
|
||||
it("caps userIds at 1000", { timeout: 5000 }, () => {
|
||||
const ids = Array.from({ length: 1001 }, (_, i) => `user:${i}`);
|
||||
expect(() =>
|
||||
SendBulkPushNotificationSchema.parse({
|
||||
userIds: ids,
|
||||
title: "T",
|
||||
body: "B",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
it("requires at least one userId", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
SendBulkPushNotificationSchema.parse({
|
||||
userIds: [],
|
||||
title: "T",
|
||||
body: "B",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DeviceTokenSchema", () => {
|
||||
it("parses a fully-populated row", { timeout: 5000 }, () => {
|
||||
const v = DeviceTokenSchema.parse({
|
||||
id: "device_token:1",
|
||||
user_id: "user:1",
|
||||
device_id: "dev1",
|
||||
token: "abc",
|
||||
platform: "ios",
|
||||
environment: "sandbox",
|
||||
is_active: true,
|
||||
last_used: "2026-01-01T00:00:00Z",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
expect(v.platform).toBe("ios");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DeviceTokenIdParamSchema", () => {
|
||||
it("rejects empty id", { timeout: 5000 }, () => {
|
||||
expect(() => DeviceTokenIdParamSchema.parse({ id: "" })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
// schema: boundary-only
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod boundary schemas for the `/push/*` route group and the APN
|
||||
* transport contract.
|
||||
*/
|
||||
|
||||
export const DevicePlatformSchema = z.enum(["ios", "android", "web"]);
|
||||
export type DevicePlatform = z.infer<typeof DevicePlatformSchema>;
|
||||
|
||||
export const DeviceEnvironmentSchema = z.enum([
|
||||
"production",
|
||||
"sandbox",
|
||||
"development",
|
||||
]);
|
||||
export type DeviceEnvironment = z.infer<typeof DeviceEnvironmentSchema>;
|
||||
|
||||
export const DeviceTokenSchema = z.object({
|
||||
id: z.string(),
|
||||
user_id: z.string(),
|
||||
site: z.string().optional(),
|
||||
device_id: z.string(),
|
||||
token: z.string(),
|
||||
platform: DevicePlatformSchema,
|
||||
environment: DeviceEnvironmentSchema,
|
||||
app_version: z.string().optional(),
|
||||
os_version: z.string().optional(),
|
||||
is_active: z.boolean(),
|
||||
last_used: z.string(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
});
|
||||
export type DeviceToken = z.infer<typeof DeviceTokenSchema>;
|
||||
|
||||
export const RegisterDeviceTokenSchema = z.object({
|
||||
deviceToken: z.string().min(1).max(1000),
|
||||
deviceId: z.string().min(1).max(255).optional(),
|
||||
deviceType: DevicePlatformSchema.optional().default("ios"),
|
||||
environment: DeviceEnvironmentSchema.optional().default("sandbox"),
|
||||
appVersion: z.string().optional(),
|
||||
osVersion: z.string().optional(),
|
||||
});
|
||||
export type RegisterDeviceToken = z.infer<typeof RegisterDeviceTokenSchema>;
|
||||
|
||||
export const RegisterDeviceTokenResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
deviceTokenId: z.string(),
|
||||
});
|
||||
export type RegisterDeviceTokenResponse = z.infer<
|
||||
typeof RegisterDeviceTokenResponseSchema
|
||||
>;
|
||||
|
||||
export const UnregisterDeviceTokenResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
});
|
||||
export type UnregisterDeviceTokenResponse = z.infer<
|
||||
typeof UnregisterDeviceTokenResponseSchema
|
||||
>;
|
||||
|
||||
export const SendPushNotificationSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
title: z.string().min(1).max(200),
|
||||
body: z.string().min(1).max(2000),
|
||||
data: z.record(z.string(), z.unknown()).optional(),
|
||||
badge: z.number().int().min(0).optional(),
|
||||
sound: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
});
|
||||
export type SendPushNotification = z.infer<typeof SendPushNotificationSchema>;
|
||||
|
||||
export const SendBulkPushNotificationSchema = z.object({
|
||||
userIds: z.array(z.string().min(1)).min(1).max(1000),
|
||||
title: z.string().min(1).max(200),
|
||||
body: z.string().min(1).max(2000),
|
||||
data: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
export type SendBulkPushNotification = z.infer<
|
||||
typeof SendBulkPushNotificationSchema
|
||||
>;
|
||||
|
||||
export const SendPushResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
sent: z.number().int().min(0),
|
||||
failed: z.number().int().min(0),
|
||||
});
|
||||
export type SendPushResponse = z.infer<typeof SendPushResponseSchema>;
|
||||
|
||||
export const SendBulkPushResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
sent: z.number().int().min(0),
|
||||
failed: z.number().int().min(0),
|
||||
totalUsers: z.number().int().min(0),
|
||||
});
|
||||
export type SendBulkPushResponse = z.infer<typeof SendBulkPushResponseSchema>;
|
||||
|
||||
export const DeviceTokenIdParamSchema = z.object({ id: z.string().min(1) });
|
||||
export type DeviceTokenIdParam = z.infer<typeof DeviceTokenIdParamSchema>;
|
||||
Reference in New Issue
Block a user