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`)
|
|
|
|
|
|
|
|
|
|
**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`
|
|
|
|
|
- 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
|
|
|
|
|
|
|
|
**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.
|