2026-04-10 18:50:08 +08:00
# Notification Inbox Protocol (Frontend <-> Backend)
This document defines the notification inbox contract for authenticated users.
Protocol verification status:
- Backend route source: `backend/src/v1/notifications/router.py`
- Backend service source: `backend/src/v1/notifications/service.py`
- Backend repository source: `backend/src/v1/notifications/repository.py`
- Backend schema source: `backend/src/v1/notifications/schemas.py`
## Compatibility strategy
- Additive evolution only.
- Existing response fields are stable and must remain backward-compatible.
- New `action` values may be added to `payload` ; unknown `action` values must be ignored by the client.
## Routes
### GET /api/v1/notifications
List notifications for the current user.
**Authorization ** : Requires authenticated session. User identity from JWT `sub` .
**Query parameters ** :
- `limit` (optional, integer, default 20, max 50): number of items per page
- `cursor` (optional, string): pagination cursor (ISO 8601 timestamp of last item's `created_at` )
2026-04-28 17:20:17 +08:00
- `locale` (optional, string): requested locale for title/body resolution. Supported values: `zh` (default), `zh_Hant` , `en` . If the requested locale is not available in the notification's i18n dict, falls back to `zh` .
2026-04-10 18:50:08 +08:00
**Response (200) ** :
``` json
{
"items" : [
{
"id" : "uuid" ,
"notificationId" : "uuid" ,
"type" : "system" ,
"title" : "Welcome" ,
"body" : "Welcome to the app!" ,
"payload" : {
"action" : "none"
} ,
"isRead" : false ,
"readAt" : null ,
"createdAt" : "2026-04-10T00:00:00Z"
}
] ,
"nextCursor" : "2026-04-09T12:00:00Z" ,
"hasMore" : true
}
```
Field rules:
- `items` : array of notification items, ordered by `createdAt` descending
- `nextCursor` : timestamp cursor for next page, `null` if no more items
- `hasMore` : boolean indicating if more items exist
- `type` : string, currently only `system`
- `payload` : discriminated union (see Payload section below)
- `isRead` : boolean
- `readAt` : ISO 8601 timestamp or `null`
2026-04-28 17:20:17 +08:00
- `title` and `body` : resolved plain strings based on the `locale` parameter. The database stores these as i18n JSONB objects (`{"zh": "...", "zh_Hant": "...", "en": "..."}` ); the API resolves the best match before returning.
2026-04-10 18:50:08 +08:00
- Results are filtered: `notifications.status = 'published'` and `notifications.deleted_at IS NULL`
### GET /api/v1/notifications/unread-count
Get the number of unread notifications for the current user.
**Authorization ** : Requires authenticated session. User identity from JWT `sub` .
**Response (200) ** :
``` json
{
"count" : 5
}
```
Field rules:
- `count` : integer `>= 0`
- Counts only notifications where `notifications.status = 'published'` and `notifications.deleted_at IS NULL`
2026-04-16 10:51:08 +08:00
### PATCH /api/v1/notifications/{notification_id}/read
2026-04-10 18:50:08 +08:00
Mark a single notification as read. Idempotent.
2026-04-16 10:51:08 +08:00
**Authorization ** : Requires authenticated session. `notification_id` must belong to the current user's `user_notifications` .
2026-04-10 18:50:08 +08:00
**Path parameters ** :
2026-04-16 10:51:08 +08:00
- `notification_id` : UUID of the `user_notifications` record
2026-04-10 18:50:08 +08:00
2026-04-28 17:20:17 +08:00
**Query parameters ** :
- `locale` (optional, string): requested locale for title/body resolution (same rules as list endpoint)
2026-04-10 18:50:08 +08:00
**Response (200) ** :
``` json
{
"id" : "uuid" ,
"notificationId" : "uuid" ,
"type" : "system" ,
"title" : "Welcome" ,
"body" : "Welcome to the app!" ,
"payload" : {
"action" : "none"
} ,
"isRead" : true ,
"readAt" : "2026-04-10T01:00:00Z" ,
"createdAt" : "2026-04-10T00:00:00Z"
}
```
**Error responses ** :
- 404 `NOTIFICATION_NOT_FOUND` : notification not found or not owned by current user
- Already-read notifications return 200 with current state (idempotent)
### PATCH /api/v1/notifications/mark-all-read
Mark all unread notifications for the current user as read. Idempotent.
**Authorization ** : Requires authenticated session. User identity from JWT `sub` .
**Response (200) ** :
``` json
{
"updatedCount" : 3
}
```
Field rules:
- `updatedCount` : integer `>= 0` , number of notifications that were actually changed from unread to read
- If all notifications are already read, returns `{ "updatedCount": 0 }`
- Only affects notifications where `notifications.status = 'published'` and `notifications.deleted_at IS NULL`
## Payload
`payload` is a discriminated union based on the `action` field.
### action = "none"
No navigation action on tap.
``` json
{
"action" : "none"
}
```
### action = "open_route"
Navigate to an in-app route.
``` json
{
"action" : "open_route" ,
"route" : "/divination/history" ,
"entityId" : "optional-uuid" ,
"tab" : "optional-tab-name"
}
```
Field rules:
- `route` : required, string, max 200 characters, app-internal route path
- `entityId` : optional, string, max 64 characters, business object ID
- `tab` : optional, string, max 32 characters, sub-page navigation parameter
- `url` : must be absent
### action = "open_url"
Open an external URL.
``` json
{
"action" : "open_url" ,
"url" : "https://example.com/page"
}
```
Field rules:
- `url` : required, string, max 500 characters, external URL
- `route` , `entityId` , `tab` : must be absent
## Error contract linkage
- RFC7807 + extension `code` , optional `params` .
- Shared registry: `docs/protocols/common/http-error-codes.md` .
- New error codes for this feature are registered in the same registry.