Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions docs/api-endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):**

Expand All @@ -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" }
Expand All @@ -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:

Expand Down
198 changes: 116 additions & 82 deletions docs/notification-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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": "<plan_id>"
"source_id": "<plan_id>",
"title": "Time for Morning Practice",
"body": "Day 3: time for your practice.",
"image_url": ""
}
```

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

Expand All @@ -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 |
Loading