diff --git a/CLAUDE.md b/CLAUDE.md index 02c9e98..48755cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -232,7 +232,17 @@ Reactions: `toggleReaction`, `listReactions` · Read state: `markAsRead` ### 16. Push Notifications Module (1 function) -`send` — `{ userIds: string[], title: string, body: string, data?: Record }`. +`send` — `{ userIds: string[], title: string, body: string, data?: Record }` +plus optional rich-payload fields, each mapped to whichever platform(s) support +it and ignored elsewhere: `sound`, `badge` (iOS), `channelId` (Android), +`priority` (`high`/`normal`), `subtitle` (iOS), `imageUrl`, `tag`, `collapseId`, +`threadId` (iOS), `ttl` (seconds), `mutableContent` (iOS). `imageUrl` works +out-of-the-box on Android/Web; on iOS it also needs a Notification Service +Extension in the app (and auto-sets `mutable-content`). Android sound on 8+ is +owned by the channel, so pair `sound` with a client-created `channelId`. The +SDK forwards the whole body unreshaped — the server zod schema +(`server/src/v7/validation/push-notifications/push-notifications.schema.ts`) is +the source of truth. Fans a push out across all of the listed users' registered devices (APNs/FCM/Web Push); returns per-user, per-device results (`{ results: { [userId]: { platform, success, reason? }[] } }`). Devices for a user with no registered devices come back as an empty array, not diff --git a/__tests__/push.test.ts b/__tests__/push.test.ts index 8e0afbf..f1f99a4 100644 --- a/__tests__/push.test.ts +++ b/__tests__/push.test.ts @@ -19,6 +19,31 @@ describe("node-sdk push — request shaping", () => { }); }); + it("send forwards the rich payload fields verbatim (no reshaping or dropping)", async () => { + const { client, projectInstance } = makeClient(); + const props = { + userIds: ["u1"], + title: "New message", + body: "You have a new message", + data: { conversationId: "c1" }, + sound: "notification.wav", + badge: 3, + channelId: "messages", + priority: "high" as const, + subtitle: "from Alice", + imageUrl: "https://ex.com/img.png", + tag: "conv-c1", + collapseId: "conv-c1", + threadId: "conv-c1", + ttl: 3600, + mutableContent: true, + }; + + await send(client, props); + + expect(projectInstance.post).toHaveBeenCalledWith("/push-notifications/send", props); + }); + it("send returns the typed response passed through as-is", async () => { const { client, projectInstance } = makeClient(); const responseBody = { diff --git a/src/modules/push/send.ts b/src/modules/push/send.ts index ca355b4..bf8ae9f 100644 --- a/src/modules/push/send.ts +++ b/src/modules/push/send.ts @@ -6,6 +6,58 @@ export interface SendPushProps { title: string; body: string; data?: Record; + /** + * Sound file to play on the device. iOS plays it directly (APNs `sound`); + * on Android 8+ the sound is owned by the notification channel, so pair this + * with `channelId` (the app must create that channel with the sound set); + * Web forwards it to your service worker. + */ + sound?: string; + /** iOS app-icon badge count (APNs `badge`). No Android equivalent. */ + badge?: number; + /** + * Android notification channel id (FCM). The channel must be created + * client-side; on Android 8+ it governs sound, importance, and vibration. + */ + channelId?: string; + /** + * Delivery priority. `high` (default) wakes the device for time-sensitive + * pushes; `normal` is power-considerate. Maps to APNs `apns-priority` and + * FCM `android.priority`. + */ + priority?: "high" | "normal"; + /** iOS subtitle line shown under the title (APNs `alert.subtitle`). */ + subtitle?: string; + /** + * Rich/big-picture image URL. Works out of the box on Android (FCM) and Web. + * On iOS it additionally requires a Notification Service Extension in your + * app; the URL is forwarded and `mutable-content` is set automatically. + */ + imageUrl?: string; + /** + * Display-replace key so notifications collapse in the UI instead of + * stacking (FCM `android.notification.tag`, Web `tag`). Use a per-conversation + * value to keep one entry per thread. + */ + tag?: string; + /** + * Transport-level collapse identifier (APNs `apns-collapse-id`, FCM + * `collapse_key`) — a newer push supersedes an undelivered one with the same id. + */ + collapseId?: string; + /** iOS notification grouping (APNs `thread-id`). */ + threadId?: string; + /** + * Time-to-live in seconds for offline devices (APNs `apns-expiration`, + * FCM `android.ttl`). After this window the provider stops retrying. + */ + ttl?: number; + /** + * iOS `mutable-content` flag, letting your Notification Service Extension + * modify the payload (e.g. attach `imageUrl`). Set implicitly when `imageUrl` + * is provided. + */ + mutableContent?: boolean; } export async function send(