diff --git a/docs/api-endpoints.md b/docs/api-endpoints.md index a0f4150..996edde 100644 --- a/docs/api-endpoints.md +++ b/docs/api-endpoints.md @@ -267,7 +267,7 @@ Processes due plan reminders from the database. Returns a **preview** of users who would receive routine notifications at the current UTC time, without sending anything. -Proxies to the backend `GET /internal/routine-notification-targets` endpoint, which matches routine time blocks whose local time equals the current minute in each user's timezone (`Routine.timezone`, with a fallback to the time block creation offset). +Proxies to the backend `GET /internal/routine-notification-targets` endpoint, which matches routine time blocks whose stored UTC time (`routine_time_blocks.time_utc`) equals the current UTC minute. Only **`PLAN`** and **`SERIES`** sessions are included. **Response (200):** @@ -284,9 +284,9 @@ Proxies to the backend `GET /internal/routine-notification-targets` endpoint, wh { "user_id": "uuid", "notification": { - "title": "Day 3", - "body": "Time for your daily practice.", - "custom_image_url": null + "title": "Day 3 — Breath Awareness", + "body": "Take 10 minutes for today's practice.", + "image_url": "https://..." }, "push_devices": [ { "token": "...", "platform": "android" } @@ -298,13 +298,25 @@ Proxies to the backend `GET /internal/routine-notification-targets` endpoint, wh } ``` -**Session types:** `PLAN`, `SERIES`, `RECITATION`, `RECITATION_COLLECTION`, `ACCUMULATION`, `TIMER` +| Field | Description | +|-------|-------------| +| `notification.title` | Resolved title sent in the push | +| `notification.body` | Resolved body sent in the push | +| `notification.image_url` | Presigned image URL sent in the push (`notification.image` and `data.image_url`). Empty when no image is resolved. | +| `source_image_url` | Group-level plan/series cover preview only; not duplicated in the FCM data payload | + +**Session types in routine dispatch:** `PLAN`, `SERIES` only --- ### `POST /internal/dispatch-routine-notifications` -Builds the same target list as the preview endpoint, then sends FCM push notifications to each device. +Builds the same target list as the preview endpoint, then sends FCM push notifications to each device. Each push includes: + +- Display notification: `title`, `body`, optional `image` +- Data payload: `session_type`, `source_id`, `title`, `body`, `image_url` + +See [notification-format.md](./notification-format.md) for resolution rules and examples. **Response (200):** Same as the preview response, plus dispatch counts: diff --git a/docs/notification-format.md b/docs/notification-format.md index 94ac658..02dd470 100644 --- a/docs/notification-format.md +++ b/docs/notification-format.md @@ -8,30 +8,35 @@ The worker sends notifications via **Firebase Cloud Messaging (FCM)** for both A Each push includes: -1. A **display notification** (`title` + `body`) shown in the system tray. -2. A **data payload** with routine session metadata the app can use for navigation. +1. A **display notification** (`title`, `body`, and optional `image`) shown in the system tray. +2. A **data payload** with routing metadata plus the resolved notification content (`title`, `body`, `image_url`). ## Push Payload ```json { "notification": { - "title": "Day 1", - "body": "Begin practice." + "title": "Day 3 — Breath Awareness", + "body": "Take 10 minutes for today's practice.", + "image": "https://cdn.example.com/plans/cover.jpg" }, "data": { "session_type": "PLAN", - "source_id": "550e8400-e29b-41d4-a716-446655440000" + "source_id": "550e8400-e29b-41d4-a716-446655440000", + "title": "Day 3 — Breath Awareness", + "body": "Take 10 minutes for today's practice.", + "image_url": "https://cdn.example.com/plans/cover.jpg" } } ``` ### Display notification -| Field | Type | Description | -|---------|--------|--------------------------------------| +| Field | Type | Description | +|---------|--------|-------------| | `title` | string | Notification title shown to the user | -| `body` | string | Notification body shown to the user | +| `body` | string | Notification body shown to the user | +| `image` | string | Optional image URL for rich notifications (Android and supported iOS setups). Omitted when no image is resolved. | ### Data payload @@ -40,18 +45,23 @@ All data values are **strings** (FCM requirement). | Field | Type | Description | |----------------|--------|-------------| | `session_type` | string | Type of routine session (see [Session types](#session-types)) | -| `source_id` | string | UUID of the related entity (plan, series, collection, etc.). Empty string (`""`) when there is no linked entity | +| `source_id` | string | UUID of the related entity (plan or series). Empty string (`""`) when there is no linked entity | +| `title` | string | Resolved notification title (same value as the display notification) | +| `body` | string | Resolved notification body (same value as the display notification) | +| `image_url` | string | Presigned HTTPS URL for the notification image. Empty string (`""`) when no image is resolved | + +The app can read routing fields (`session_type`, `source_id`) and content fields (`title`, `body`, `image_url`) from `message.data` in both foreground and background handlers. ## Session types -| `session_type` | `source_id` refers to | -|--------------------------|----------------------------| -| `PLAN` | Plan UUID | -| `SERIES` | Series UUID | -| `RECITATION` | Recitation UUID (if set) | -| `RECITATION_COLLECTION` | Recitation collection UUID | -| `ACCUMULATION` | Accumulation UUID (if set) | -| `TIMER` | Empty string | +Routine notifications are sent only for **`PLAN`** and **`SERIES`** sessions. + +| `session_type` | `source_id` refers to | +|----------------|------------------------| +| `PLAN` | Plan UUID | +| `SERIES` | Series UUID | + +Plan reminder dispatches (enrollment API) always use `session_type: "PLAN"`. ## Default content @@ -61,41 +71,60 @@ When no custom content is configured: |---------|----------------------------------| | `title` | `WebBuddhist` | | `body` | `Time for your daily practice.` | +| `image_url` | Empty string | + +Config keys: `NOTIFICATION_DEFAULT_TITLE`, `NOTIFICATION_DEFAULT_BODY`. + +## How title, body, and image are resolved -## How title and body are resolved +Content is resolved by the **backend** when building routine notification targets. The worker forwards the resolved values into the FCM notification and data payload. ### Routine notifications (time-block based) -Triggered when a user's routine time block matches the current local time. +Triggered when a user's routine time block matches the current UTC minute (`routine_time_blocks.time_utc`). -| Session type | Title | Body | -|---------------------------|-------------------------------|-------------------------------| -| `PLAN` | Day notification title from DB | Day notification body from DB | -| `SERIES` | Series metadata title | Default body | -| `RECITATION_COLLECTION` | Collection name | Default body | -| `RECITATION` | Default title | Default body | -| `ACCUMULATION` | Default title | Default body | -| `TIMER` | Default title | Default body | +| Session type | Title | Body | Image (`image_url`) | +|--------------|-------|------|---------------------| +| `PLAN` | Day notification title from `day_notifications`, or plan title if no day copy exists | Day notification body from `day_notifications`, or default body | See [Plan image rules](#plan-image-rules) | +| `SERIES` | Series metadata title, or default title | Default body | Series cover image (presigned), or empty string | -For `PLAN`, the worker calculates the user's current day from plan progress and loads that day's notification copy from the database. +For `PLAN`, the backend calculates the user's current day from plan progress and loads that day's notification copy from `day_notifications` (linked to the plan item / day). + +#### Plan image rules + +| Condition | `image_url` | +|-----------|-------------| +| Day notification exists with `image_type = CUSTOM` | Presigned URL for `day_notifications.image_url` | +| Day notification exists with `image_type = PLAN`, or no day notification | Presigned plan cover image (`plans.image_url`) | +| No plan cover available | Empty string | + +#### Series image rules + +| Condition | `image_url` | +|-----------|-------------| +| Series has a cover image | Presigned series cover image (`series.image`) | +| No series cover available | Empty string | ### Plan reminders (enrollment API) Used when the app enrolls reminders via `POST /api/v1/notifications/reminders`. -Body resolution priority: +**Title/body resolution:** 1. `routine.message_template` (if set) 2. `"Time for {plan_name}"` (if `plan_name` is set) 3. `"Day {current_day_number}: time for your practice."` (if `current_day_number` is set) -4. Default body +4. Default title/body -Data payload for plan reminders: +**Data payload** includes `title` and `body` but not `image_url` (empty string): ```json { "session_type": "PLAN", - "source_id": "" + "source_id": "", + "title": "Time for Morning Practice", + "body": "Day 3: time for your practice.", + "image_url": "" } ``` @@ -105,13 +134,14 @@ Data payload for plan reminders: ```dart FirebaseMessaging.onMessage.listen((RemoteMessage message) { - final title = message.notification?.title; - final body = message.notification?.body; + final title = message.notification?.title ?? message.data['title']; + final body = message.notification?.body ?? message.data['body']; + final imageUrl = message.notification?.android?.imageUrl + ?? message.data['image_url']; final sessionType = message.data['session_type']; final sourceId = message.data['source_id']; - // Parse sourceId as UUID when non-empty if (sourceId != null && sourceId.isNotEmpty) { // navigate to content for sessionType + sourceId } @@ -128,8 +158,10 @@ FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) { ```kotlin override fun onMessageReceived(remoteMessage: RemoteMessage) { - val title = remoteMessage.notification?.title - val body = remoteMessage.notification?.body + val title = remoteMessage.notification?.title ?: remoteMessage.data["title"] + val body = remoteMessage.notification?.body ?: remoteMessage.data["body"] + val imageUrl = remoteMessage.notification?.imageUrl + ?: remoteMessage.data["image_url"]?.takeIf { it.isNotEmpty() } val sessionType = remoteMessage.data["session_type"] val sourceId = remoteMessage.data["source_id"] @@ -143,89 +175,94 @@ When the user taps a notification, read the data keys from the FCM payload: ```swift let sessionType = userInfo["session_type"] as? String let sourceId = userInfo["source_id"] as? String +let title = userInfo["title"] as? String +let body = userInfo["body"] as? String +let imageUrl = (userInfo["image_url"] as? String).flatMap { $0.isEmpty ? nil : $0 } ``` ## Foreground vs background | App state | Behavior | |------------------------|----------| -| Background or killed | OS shows the notification using `title` and `body` | +| Background or killed | OS shows the notification using `title`, `body`, and `image` when present | | Foreground | App receives the message in the FCM handler; show UI manually if needed | The `data` payload is available in both cases. Use it to route the user when they tap the notification. ## Example payloads -### Plan session +### Plan session (custom day notification + plan image) ```json { "notification": { "title": "Day 3 — Breath Awareness", - "body": "Take 10 minutes for today's practice." + "body": "Take 10 minutes for today's practice.", + "image": "https://cdn.example.com/plans/cover.jpg" }, "data": { "session_type": "PLAN", - "source_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + "source_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "title": "Day 3 — Breath Awareness", + "body": "Take 10 minutes for today's practice.", + "image_url": "https://cdn.example.com/plans/cover.jpg" } } ``` +### Plan session (custom day image) + +When the day notification uses `image_type = CUSTOM`, `image_url` points to the day-specific asset instead of the plan cover. + ### Series session ```json { "notification": { "title": "Morning Teachings", - "body": "Time for your daily practice." + "body": "Time for your daily practice.", + "image": "https://cdn.example.com/series/cover.jpg" }, "data": { "session_type": "SERIES", - "source_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901" - } -} -``` - -### Timer session (no linked entity) - -```json -{ - "notification": { - "title": "WebBuddhist", - "body": "Time for your daily practice." - }, - "data": { - "session_type": "TIMER", - "source_id": "" + "source_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", + "title": "Morning Teachings", + "body": "Time for your daily practice.", + "image_url": "https://cdn.example.com/series/cover.jpg" } } ``` -### Accumulation session +### Plan reminder (no image) ```json { "notification": { - "title": "WebBuddhist", - "body": "Time for your daily practice." + "title": "Time for Morning Practice", + "body": "Day 3: time for your practice." }, "data": { - "session_type": "ACCUMULATION", - "source_id": "c3d4e5f6-a7b8-9012-cdef-123456789012" + "session_type": "PLAN", + "source_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "title": "Time for Morning Practice", + "body": "Day 3: time for your practice.", + "image_url": "" } } ``` -## What is not included in the push - -These fields exist server-side but are **not** sent to the device: +## Preview API vs push payload -- `custom_image_url` -- `source_image_url` -- `user_id` -- Deep link URL +The backend preview endpoint (`GET /internal/routine-notification-targets`) returns additional server-side fields that are **not** sent to the device: -Images and rich notification content are not part of the current push payload. +| Field | In preview API | In FCM push | +|-------|----------------|-------------| +| `notification.title` | Yes | Yes (`notification.title` and `data.title`) | +| `notification.body` | Yes | Yes (`notification.body` and `data.body`) | +| `notification.image_url` | Yes | Yes (`notification.image` and `data.image_url`) | +| `source_image_url` | Yes (group-level plan/series cover) | No — use `notification.image_url` per user | +| `user_id` | Yes | No | +| Deep link URL | No | No | ## Related API (enrollment only) @@ -244,14 +281,11 @@ Routine notifications are driven by routines and time blocks in the shared datab When the user taps a notification: 1. Read `session_type` and `source_id` from `message.data`. -2. If `source_id` is empty, open the app's default home or practice screen. -3. Otherwise, navigate to the screen for that session type and entity ID: - -| `session_type` | Suggested route | -|-------------------------|------------------------| -| `PLAN` | Plan detail / day view | -| `SERIES` | Series player | -| `RECITATION` | Recitation view | -| `RECITATION_COLLECTION` | Collection view | -| `ACCUMULATION` | Accumulation view | -| `TIMER` | Timer screen | +2. Optionally use `title`, `body`, and `image_url` from `message.data` for in-app UI. +3. If `source_id` is empty, open the app's default home or practice screen. +4. Otherwise, navigate to the screen for that session type and entity ID: + +| `session_type` | Suggested route | +|----------------|------------------------| +| `PLAN` | Plan detail / day view | +| `SERIES` | Series player |