Skip to content

Commit 00f4504

Browse files
authored
Merge pull request #9 from OpenPecha/notification
Update API documentation for routine notifications and payload structure
2 parents a4e5a60 + 6eaff2d commit 00f4504

2 files changed

Lines changed: 134 additions & 88 deletions

File tree

docs/api-endpoints.md

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ Processes due plan reminders from the database.
267267

268268
Returns a **preview** of users who would receive routine notifications at the current UTC time, without sending anything.
269269

270-
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).
270+
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.
271271

272272
**Response (200):**
273273

@@ -284,9 +284,9 @@ Proxies to the backend `GET /internal/routine-notification-targets` endpoint, wh
284284
{
285285
"user_id": "uuid",
286286
"notification": {
287-
"title": "Day 3",
288-
"body": "Time for your daily practice.",
289-
"custom_image_url": null
287+
"title": "Day 3 — Breath Awareness",
288+
"body": "Take 10 minutes for today's practice.",
289+
"image_url": "https://..."
290290
},
291291
"push_devices": [
292292
{ "token": "...", "platform": "android" }
@@ -298,13 +298,25 @@ Proxies to the backend `GET /internal/routine-notification-targets` endpoint, wh
298298
}
299299
```
300300

301-
**Session types:** `PLAN`, `SERIES`, `RECITATION`, `RECITATION_COLLECTION`, `ACCUMULATION`, `TIMER`
301+
| Field | Description |
302+
|-------|-------------|
303+
| `notification.title` | Resolved title sent in the push |
304+
| `notification.body` | Resolved body sent in the push |
305+
| `notification.image_url` | Presigned image URL sent in the push (`notification.image` and `data.image_url`). Empty when no image is resolved. |
306+
| `source_image_url` | Group-level plan/series cover preview only; not duplicated in the FCM data payload |
307+
308+
**Session types in routine dispatch:** `PLAN`, `SERIES` only
302309

303310
---
304311

305312
### `POST /internal/dispatch-routine-notifications`
306313

307-
Builds the same target list as the preview endpoint, then sends FCM push notifications to each device.
314+
Builds the same target list as the preview endpoint, then sends FCM push notifications to each device. Each push includes:
315+
316+
- Display notification: `title`, `body`, optional `image`
317+
- Data payload: `session_type`, `source_id`, `title`, `body`, `image_url`
318+
319+
See [notification-format.md](./notification-format.md) for resolution rules and examples.
308320

309321
**Response (200):** Same as the preview response, plus dispatch counts:
310322

docs/notification-format.md

Lines changed: 116 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -8,30 +8,35 @@ The worker sends notifications via **Firebase Cloud Messaging (FCM)** for both A
88

99
Each push includes:
1010

11-
1. A **display notification** (`title` + `body`) shown in the system tray.
12-
2. A **data payload** with routine session metadata the app can use for navigation.
11+
1. A **display notification** (`title`, `body`, and optional `image`) shown in the system tray.
12+
2. A **data payload** with routing metadata plus the resolved notification content (`title`, `body`, `image_url`).
1313

1414
## Push Payload
1515

1616
```json
1717
{
1818
"notification": {
19-
"title": "Day 1",
20-
"body": "Begin practice."
19+
"title": "Day 3 — Breath Awareness",
20+
"body": "Take 10 minutes for today's practice.",
21+
"image": "https://cdn.example.com/plans/cover.jpg"
2122
},
2223
"data": {
2324
"session_type": "PLAN",
24-
"source_id": "550e8400-e29b-41d4-a716-446655440000"
25+
"source_id": "550e8400-e29b-41d4-a716-446655440000",
26+
"title": "Day 3 — Breath Awareness",
27+
"body": "Take 10 minutes for today's practice.",
28+
"image_url": "https://cdn.example.com/plans/cover.jpg"
2529
}
2630
}
2731
```
2832

2933
### Display notification
3034

31-
| Field | Type | Description |
32-
|---------|--------|--------------------------------------|
35+
| Field | Type | Description |
36+
|---------|--------|-------------|
3337
| `title` | string | Notification title shown to the user |
34-
| `body` | string | Notification body shown to the user |
38+
| `body` | string | Notification body shown to the user |
39+
| `image` | string | Optional image URL for rich notifications (Android and supported iOS setups). Omitted when no image is resolved. |
3540

3641
### Data payload
3742

@@ -40,18 +45,23 @@ All data values are **strings** (FCM requirement).
4045
| Field | Type | Description |
4146
|----------------|--------|-------------|
4247
| `session_type` | string | Type of routine session (see [Session types](#session-types)) |
43-
| `source_id` | string | UUID of the related entity (plan, series, collection, etc.). Empty string (`""`) when there is no linked entity |
48+
| `source_id` | string | UUID of the related entity (plan or series). Empty string (`""`) when there is no linked entity |
49+
| `title` | string | Resolved notification title (same value as the display notification) |
50+
| `body` | string | Resolved notification body (same value as the display notification) |
51+
| `image_url` | string | Presigned HTTPS URL for the notification image. Empty string (`""`) when no image is resolved |
52+
53+
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.
4454

4555
## Session types
4656

47-
| `session_type` | `source_id` refers to |
48-
|--------------------------|----------------------------|
49-
| `PLAN` | Plan UUID |
50-
| `SERIES` | Series UUID |
51-
| `RECITATION` | Recitation UUID (if set) |
52-
| `RECITATION_COLLECTION` | Recitation collection UUID |
53-
| `ACCUMULATION` | Accumulation UUID (if set) |
54-
| `TIMER` | Empty string |
57+
Routine notifications are sent only for **`PLAN`** and **`SERIES`** sessions.
58+
59+
| `session_type` | `source_id` refers to |
60+
|----------------|------------------------|
61+
| `PLAN` | Plan UUID |
62+
| `SERIES` | Series UUID |
63+
64+
Plan reminder dispatches (enrollment API) always use `session_type: "PLAN"`.
5565

5666
## Default content
5767

@@ -61,41 +71,60 @@ When no custom content is configured:
6171
|---------|----------------------------------|
6272
| `title` | `WebBuddhist` |
6373
| `body` | `Time for your daily practice.` |
74+
| `image_url` | Empty string |
75+
76+
Config keys: `NOTIFICATION_DEFAULT_TITLE`, `NOTIFICATION_DEFAULT_BODY`.
77+
78+
## How title, body, and image are resolved
6479

65-
## How title and body are resolved
80+
Content is resolved by the **backend** when building routine notification targets. The worker forwards the resolved values into the FCM notification and data payload.
6681

6782
### Routine notifications (time-block based)
6883

69-
Triggered when a user's routine time block matches the current local time.
84+
Triggered when a user's routine time block matches the current UTC minute (`routine_time_blocks.time_utc`).
7085

71-
| Session type | Title | Body |
72-
|---------------------------|-------------------------------|-------------------------------|
73-
| `PLAN` | Day notification title from DB | Day notification body from DB |
74-
| `SERIES` | Series metadata title | Default body |
75-
| `RECITATION_COLLECTION` | Collection name | Default body |
76-
| `RECITATION` | Default title | Default body |
77-
| `ACCUMULATION` | Default title | Default body |
78-
| `TIMER` | Default title | Default body |
86+
| Session type | Title | Body | Image (`image_url`) |
87+
|--------------|-------|------|---------------------|
88+
| `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) |
89+
| `SERIES` | Series metadata title, or default title | Default body | Series cover image (presigned), or empty string |
7990

80-
For `PLAN`, the worker calculates the user's current day from plan progress and loads that day's notification copy from the database.
91+
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).
92+
93+
#### Plan image rules
94+
95+
| Condition | `image_url` |
96+
|-----------|-------------|
97+
| Day notification exists with `image_type = CUSTOM` | Presigned URL for `day_notifications.image_url` |
98+
| Day notification exists with `image_type = PLAN`, or no day notification | Presigned plan cover image (`plans.image_url`) |
99+
| No plan cover available | Empty string |
100+
101+
#### Series image rules
102+
103+
| Condition | `image_url` |
104+
|-----------|-------------|
105+
| Series has a cover image | Presigned series cover image (`series.image`) |
106+
| No series cover available | Empty string |
81107

82108
### Plan reminders (enrollment API)
83109

84110
Used when the app enrolls reminders via `POST /api/v1/notifications/reminders`.
85111

86-
Body resolution priority:
112+
**Title/body resolution:**
87113

88114
1. `routine.message_template` (if set)
89115
2. `"Time for {plan_name}"` (if `plan_name` is set)
90116
3. `"Day {current_day_number}: time for your practice."` (if `current_day_number` is set)
91-
4. Default body
117+
4. Default title/body
92118

93-
Data payload for plan reminders:
119+
**Data payload** includes `title` and `body` but not `image_url` (empty string):
94120

95121
```json
96122
{
97123
"session_type": "PLAN",
98-
"source_id": "<plan_id>"
124+
"source_id": "<plan_id>",
125+
"title": "Time for Morning Practice",
126+
"body": "Day 3: time for your practice.",
127+
"image_url": ""
99128
}
100129
```
101130

@@ -105,13 +134,14 @@ Data payload for plan reminders:
105134

106135
```dart
107136
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
108-
final title = message.notification?.title;
109-
final body = message.notification?.body;
137+
final title = message.notification?.title ?? message.data['title'];
138+
final body = message.notification?.body ?? message.data['body'];
139+
final imageUrl = message.notification?.android?.imageUrl
140+
?? message.data['image_url'];
110141
111142
final sessionType = message.data['session_type'];
112143
final sourceId = message.data['source_id'];
113144
114-
// Parse sourceId as UUID when non-empty
115145
if (sourceId != null && sourceId.isNotEmpty) {
116146
// navigate to content for sessionType + sourceId
117147
}
@@ -128,8 +158,10 @@ FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
128158

129159
```kotlin
130160
override fun onMessageReceived(remoteMessage: RemoteMessage) {
131-
val title = remoteMessage.notification?.title
132-
val body = remoteMessage.notification?.body
161+
val title = remoteMessage.notification?.title ?: remoteMessage.data["title"]
162+
val body = remoteMessage.notification?.body ?: remoteMessage.data["body"]
163+
val imageUrl = remoteMessage.notification?.imageUrl
164+
?: remoteMessage.data["image_url"]?.takeIf { it.isNotEmpty() }
133165

134166
val sessionType = remoteMessage.data["session_type"]
135167
val sourceId = remoteMessage.data["source_id"]
@@ -143,89 +175,94 @@ When the user taps a notification, read the data keys from the FCM payload:
143175
```swift
144176
let sessionType = userInfo["session_type"] as? String
145177
let sourceId = userInfo["source_id"] as? String
178+
let title = userInfo["title"] as? String
179+
let body = userInfo["body"] as? String
180+
let imageUrl = (userInfo["image_url"] as? String).flatMap { $0.isEmpty ? nil : $0 }
146181
```
147182

148183
## Foreground vs background
149184

150185
| App state | Behavior |
151186
|------------------------|----------|
152-
| Background or killed | OS shows the notification using `title` and `body` |
187+
| Background or killed | OS shows the notification using `title`, `body`, and `image` when present |
153188
| Foreground | App receives the message in the FCM handler; show UI manually if needed |
154189

155190
The `data` payload is available in both cases. Use it to route the user when they tap the notification.
156191

157192
## Example payloads
158193

159-
### Plan session
194+
### Plan session (custom day notification + plan image)
160195

161196
```json
162197
{
163198
"notification": {
164199
"title": "Day 3 — Breath Awareness",
165-
"body": "Take 10 minutes for today's practice."
200+
"body": "Take 10 minutes for today's practice.",
201+
"image": "https://cdn.example.com/plans/cover.jpg"
166202
},
167203
"data": {
168204
"session_type": "PLAN",
169-
"source_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
205+
"source_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
206+
"title": "Day 3 — Breath Awareness",
207+
"body": "Take 10 minutes for today's practice.",
208+
"image_url": "https://cdn.example.com/plans/cover.jpg"
170209
}
171210
}
172211
```
173212

213+
### Plan session (custom day image)
214+
215+
When the day notification uses `image_type = CUSTOM`, `image_url` points to the day-specific asset instead of the plan cover.
216+
174217
### Series session
175218

176219
```json
177220
{
178221
"notification": {
179222
"title": "Morning Teachings",
180-
"body": "Time for your daily practice."
223+
"body": "Time for your daily practice.",
224+
"image": "https://cdn.example.com/series/cover.jpg"
181225
},
182226
"data": {
183227
"session_type": "SERIES",
184-
"source_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901"
185-
}
186-
}
187-
```
188-
189-
### Timer session (no linked entity)
190-
191-
```json
192-
{
193-
"notification": {
194-
"title": "WebBuddhist",
195-
"body": "Time for your daily practice."
196-
},
197-
"data": {
198-
"session_type": "TIMER",
199-
"source_id": ""
228+
"source_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
229+
"title": "Morning Teachings",
230+
"body": "Time for your daily practice.",
231+
"image_url": "https://cdn.example.com/series/cover.jpg"
200232
}
201233
}
202234
```
203235

204-
### Accumulation session
236+
### Plan reminder (no image)
205237

206238
```json
207239
{
208240
"notification": {
209-
"title": "WebBuddhist",
210-
"body": "Time for your daily practice."
241+
"title": "Time for Morning Practice",
242+
"body": "Day 3: time for your practice."
211243
},
212244
"data": {
213-
"session_type": "ACCUMULATION",
214-
"source_id": "c3d4e5f6-a7b8-9012-cdef-123456789012"
245+
"session_type": "PLAN",
246+
"source_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
247+
"title": "Time for Morning Practice",
248+
"body": "Day 3: time for your practice.",
249+
"image_url": ""
215250
}
216251
}
217252
```
218253

219-
## What is not included in the push
220-
221-
These fields exist server-side but are **not** sent to the device:
254+
## Preview API vs push payload
222255

223-
- `custom_image_url`
224-
- `source_image_url`
225-
- `user_id`
226-
- Deep link URL
256+
The backend preview endpoint (`GET /internal/routine-notification-targets`) returns additional server-side fields that are **not** sent to the device:
227257

228-
Images and rich notification content are not part of the current push payload.
258+
| Field | In preview API | In FCM push |
259+
|-------|----------------|-------------|
260+
| `notification.title` | Yes | Yes (`notification.title` and `data.title`) |
261+
| `notification.body` | Yes | Yes (`notification.body` and `data.body`) |
262+
| `notification.image_url` | Yes | Yes (`notification.image` and `data.image_url`) |
263+
| `source_image_url` | Yes (group-level plan/series cover) | No — use `notification.image_url` per user |
264+
| `user_id` | Yes | No |
265+
| Deep link URL | No | No |
229266

230267
## Related API (enrollment only)
231268

@@ -244,14 +281,11 @@ Routine notifications are driven by routines and time blocks in the shared datab
244281
When the user taps a notification:
245282

246283
1. Read `session_type` and `source_id` from `message.data`.
247-
2. If `source_id` is empty, open the app's default home or practice screen.
248-
3. Otherwise, navigate to the screen for that session type and entity ID:
249-
250-
| `session_type` | Suggested route |
251-
|-------------------------|------------------------|
252-
| `PLAN` | Plan detail / day view |
253-
| `SERIES` | Series player |
254-
| `RECITATION` | Recitation view |
255-
| `RECITATION_COLLECTION` | Collection view |
256-
| `ACCUMULATION` | Accumulation view |
257-
| `TIMER` | Timer screen |
284+
2. Optionally use `title`, `body`, and `image_url` from `message.data` for in-app UI.
285+
3. If `source_id` is empty, open the app's default home or practice screen.
286+
4. Otherwise, navigate to the screen for that session type and entity ID:
287+
288+
| `session_type` | Suggested route |
289+
|----------------|------------------------|
290+
| `PLAN` | Plan detail / day view |
291+
| `SERIES` | Series player |

0 commit comments

Comments
 (0)