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
45 changes: 45 additions & 0 deletions docs/api-endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ This document describes every HTTP endpoint exposed by the WebBuddhist Worker AP
| POST | `/internal/dispatch-due-notifications` | `X-Dispatch-Token` | Send due plan reminders |
| GET | `/internal/routine-notification-targets` | `X-Dispatch-Token` | Preview routine notification targets |
| POST | `/internal/dispatch-routine-notifications` | `X-Dispatch-Token` | Send routine notifications |
| POST | `/internal/dispatch-verse-of-day-notifications` | `X-Dispatch-Token` | Send verse-of-the-day notifications |
| POST | `/internal/send-test-notification` | `X-Dispatch-Token` | Send a test push notification |

---
Expand Down Expand Up @@ -297,6 +298,50 @@ Devices on platforms without push configuration are counted as `skipped`.

---

### `POST /internal/dispatch-verse-of-day-notifications`

Fetches verse-of-the-day notification targets from the backend `GET /internal/verse-of-day-notification-targets` endpoint, then sends FCM push notifications to each device. The backend matches each user's push devices whose IANA timezone currently reads 10:00 local time (users without a stored timezone default to UTC), and resolves the verse text in that user's preferred language (defaulting to English when unset or untranslated). Users with no verse published for their local date, or with no verse text in either their language or the English fallback, are omitted from the target list.

Each push includes:

- Display notification: `title` (`NOTIFICATION_DEFAULT_TITLE`), `body` (the resolved verse text), optional `image`
- Data payload: `notification_type=VERSE_OF_DAY`, `session_type=VERSE_OF_DAY`, `title`, `body`, `image_url`

**Headers:**

| Header | Required | Description |
|--------|----------|-------------|
| `X-Dispatch-Token` | Yes | Dispatch secret token |

**Response (200):**

```json
{
"generated_at": "2026-06-30T04:15:00Z",
"users": [
{
"user_id": "uuid",
"notification": {
"title": "WebBuddhist",
"body": "May all beings be happy and free from suffering.",
"image_url": "https://..."
},
"push_devices": [
{"token": "fcm-token", "platform": "android"}
]
}
],
"processed": 1,
"sent": 1,
"failed": 0,
"skipped": 0
}
```

This endpoint is intended to be called every minute by an external scheduler (e.g. Cloud Scheduler), the same way `/internal/dispatch-routine-notifications` is — each minute a different set of users crosses their local 10:00 threshold.

---

### `POST /internal/send-test-notification`

Sends a push notification directly for testing. Does not read from or write to the reminder schedule.
Expand Down
35 changes: 35 additions & 0 deletions docs/notification-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,15 @@ Routine notifications are sent only for **`PLAN`** and **`SERIES`** sessions.
| `PLAN` | Plan UUID |
| `SERIES` | Series UUID |
| `CHAT` | Chat room UUID |
| `VERSE_OF_DAY` | None (`source_id` field not present) |

Plan reminder dispatches (enrollment API) always use `session_type: "PLAN"`.

Chat message pushes use `session_type: "CHAT"` plus dedicated routing fields
(`notification_type`, `chat_kind`, `room_id`, `message_id`, `sender_id`, `group_id`).

Verse-of-the-day pushes use `session_type: "VERSE_OF_DAY"` plus `notification_type: "VERSE_OF_DAY"`.

## Default content

When no custom content is configured:
Expand Down Expand Up @@ -109,6 +112,18 @@ For `PLAN`, the backend calculates the user's current day from plan progress and
| Series has a cover image | Presigned series cover image (`series.image`) |
| No series cover available | Empty string |

### Verse-of-the-day notifications (per-user timezone + language)

Triggered when a user's push devices' IANA timezone (from `user_metadata.timezone`, defaulting to `UTC` when unset) currently reads **10:00 local time**. The backend computes this dynamically per user on every poll (not a stored UTC time), so it stays correct across DST.

| Field | Resolution |
|-------|------------|
| `title` | `NOTIFICATION_DEFAULT_TITLE` (backend config; verse-of-day has no per-verse title) |
| `body` | The day's verse text (`verse_metadata.verse`) in the user's language (`user_metadata.language`, defaulting to `en` when unset), falling back to the English (`en`) translation when the user's language isn't available |
| `image_url` | Presigned URL from the verse's `image_urls`, or empty when none |

If no verse is published for the user's local date, or neither the user's language nor the `en` fallback has translated text, that user is omitted from the target list for that poll (no notification is sent).

### Plan reminders (enrollment API)

Used when the app enrolls reminders via `POST /api/v1/notifications/reminders`.
Expand Down Expand Up @@ -255,6 +270,25 @@ When the day notification uses `image_type = CUSTOM`, `image_url` points to the
}
```

### Verse of the day

```json
{
"notification": {
"title": "WebBuddhist",
"body": "May all beings be happy and free from suffering.",
"image": "https://cdn.example.com/verse-of-day/2026-06-30.jpg"
},
"data": {
"notification_type": "VERSE_OF_DAY",
"session_type": "VERSE_OF_DAY",
"title": "WebBuddhist",
"body": "May all beings be happy and free from suffering.",
"image_url": "https://cdn.example.com/verse-of-day/2026-06-30.jpg"
}
}
```

### Chat message (direct)

```json
Expand Down Expand Up @@ -369,3 +403,4 @@ When the user taps a notification:
| `PLAN` | Plan detail / day view |
| `SERIES` | Series player |
| `CHAT` | Chat room / DM thread using `room_id` (`chat_kind` + optional `group_id`) |
| `VERSE_OF_DAY` | App home / verse-of-day screen (no linked entity) |
37 changes: 37 additions & 0 deletions tests/notifications/test_backend_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,43 @@ async def test_defaults_to_first_page(self):
assert http_client.get.await_args.kwargs["params"] == {"skip": 0, "limit": 100}


class TestFetchVerseOfDayNotificationTargets:
@pytest.mark.asyncio
async def test_returns_parsed_targets(self):
user_id = uuid4()
response = _json_response(
{
"generated_at": "2026-01-01T10:00:00Z",
"users": [
{
"user_id": str(user_id),
"notification": {"title": "WebBuddhist", "body": "May all beings be happy."},
"push_devices": [{"token": "token-1", "platform": "android"}],
}
],
}
)
client_patch, http_client = _patch_async_client(response)

with client_patch, _patch_config():
targets = await backend_client.fetch_verse_of_day_notification_targets()

assert targets.users[0].user_id == user_id
assert targets.users[0].notification.body == "May all beings be happy."
assert http_client.get.await_args.args[0] == (
"http://backend.test/internal/verse-of-day-notification-targets"
)
assert http_client.get.await_args.kwargs["headers"] == {"X-Dispatch-Token": "dispatch-token"}

@pytest.mark.asyncio
async def test_raises_on_error_status(self):
client_patch, _ = _patch_async_client(_json_response({}, status_code=500))

with client_patch, _patch_config():
with pytest.raises(httpx.HTTPStatusError):
await backend_client.fetch_verse_of_day_notification_targets()


class TestDeactivatePushDevice:
@pytest.mark.asyncio
async def test_posts_push_device_id(self):
Expand Down
63 changes: 63 additions & 0 deletions tests/notifications/test_notification_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,69 @@ def test_dispatch_routine_notifications_success(self, mock_dispatch, client, mon
mock_dispatch.assert_called_once()


class TestDispatchVerseOfDayNotificationsEndpoint:
def test_dispatch_verse_of_day_notifications_requires_token(self, client):
response = client.post("/api/v1/internal/dispatch-verse-of-day-notifications")
assert response.status_code == 422

def test_dispatch_verse_of_day_notifications_rejects_invalid_token(self, client, monkeypatch):
monkeypatch.setenv("NOTIFICATION_DISPATCH_SECRET_TOKEN", "secret-token")
response = client.post(
"/api/v1/internal/dispatch-verse-of-day-notifications",
headers={"X-Dispatch-Token": "wrong-token"},
)
assert response.status_code == 401

@patch(
"worker_api.notifications.internal_views.dispatch_verse_of_day_notifications_service",
new_callable=AsyncMock,
)
def test_dispatch_verse_of_day_notifications_success(self, mock_dispatch, client, monkeypatch):
monkeypatch.setenv("NOTIFICATION_DISPATCH_SECRET_TOKEN", "secret-token")
from datetime import datetime, timezone
from uuid import uuid4

from worker_api.notifications.schemas import (
DispatchVerseOfDayNotificationsResponse,
VerseOfDayNotificationContent,
VerseOfDayNotificationUserTarget,
VerseOfDayPushDeviceTarget,
)

user_id = uuid4()
mock_dispatch.return_value = DispatchVerseOfDayNotificationsResponse(
generated_at=datetime(2026, 6, 23, 10, 0, tzinfo=timezone.utc),
users=[
VerseOfDayNotificationUserTarget(
user_id=user_id,
notification=VerseOfDayNotificationContent(
title="WebBuddhist",
body="May all beings be happy.",
),
push_devices=[
VerseOfDayPushDeviceTarget(token="fcm-token", platform="android"),
],
)
],
processed=1,
sent=1,
failed=0,
skipped=0,
)

response = client.post(
"/api/v1/internal/dispatch-verse-of-day-notifications",
headers={"X-Dispatch-Token": "secret-token"},
)

assert response.status_code == 200
payload = response.json()
assert payload["sent"] == 1
assert payload["processed"] == 1
assert payload["users"][0]["notification"]["body"] == "May all beings be happy."
mock_dispatch.assert_called_once()


class TestSendTestNotificationEndpoint:
def test_send_test_notification_requires_token(self, client):
response = client.post(
Expand Down
96 changes: 96 additions & 0 deletions tests/notifications/test_verse_of_day_dispatch_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
from datetime import datetime, timezone
from unittest.mock import AsyncMock, patch
from uuid import uuid4

import pytest

from worker_api.notifications.schemas import (
VerseOfDayNotificationContent,
VerseOfDayNotificationTargetsResponse,
VerseOfDayNotificationUserTarget,
VerseOfDayPushDeviceTarget,
)
from worker_api.notifications.services import verse_of_day_dispatch_service as svc


def _targets(users):
return VerseOfDayNotificationTargetsResponse(
generated_at=datetime(2026, 6, 23, 10, 0, tzinfo=timezone.utc),
users=users,
)


def _user(*, devices):
return VerseOfDayNotificationUserTarget(
user_id=uuid4(),
notification=VerseOfDayNotificationContent(title="WebBuddhist", body="Verse text"),
push_devices=devices,
)


class TestDispatchVerseOfDayNotificationsService:
@pytest.mark.asyncio
@patch.object(svc, "get_bool", return_value=False)
async def test_short_circuits_when_dispatch_disabled(self, _get_bool):
user = _user(devices=[VerseOfDayPushDeviceTarget(token="token-1", platform="android")])
with patch.object(svc, "get_verse_of_day_notification_targets", new=AsyncMock(return_value=_targets([user]))):
result = await svc.dispatch_verse_of_day_notifications_service()

assert result.processed == 0
assert result.sent == 0
assert result.failed == 0
assert result.skipped == 0
assert result.users == [user]

@pytest.mark.asyncio
@patch.object(svc, "get_bool", return_value=True)
@patch.object(svc, "is_push_configured", return_value=True)
@patch.object(svc, "send_verse_of_day_push_notification", new_callable=AsyncMock)
async def test_sends_one_notification_per_device(self, mock_send, _is_configured, _get_bool):
user = _user(
devices=[
VerseOfDayPushDeviceTarget(token="token-1", platform="android"),
VerseOfDayPushDeviceTarget(token="token-2", platform="ios"),
]
)
with patch.object(svc, "get_verse_of_day_notification_targets", new=AsyncMock(return_value=_targets([user]))):
result = await svc.dispatch_verse_of_day_notifications_service()

assert result.processed == 2
assert result.sent == 2
assert result.failed == 0
assert result.skipped == 0
assert mock_send.await_count == 2

@pytest.mark.asyncio
@patch.object(svc, "get_bool", return_value=True)
@patch.object(svc, "is_push_configured", return_value=True)
@patch.object(svc, "send_verse_of_day_push_notification", new_callable=AsyncMock)
async def test_one_device_failure_does_not_abort_the_loop(self, mock_send, _is_configured, _get_bool):
mock_send.side_effect = [Exception("fcm failure"), None]
user = _user(
devices=[
VerseOfDayPushDeviceTarget(token="token-1", platform="android"),
VerseOfDayPushDeviceTarget(token="token-2", platform="android"),
]
)
with patch.object(svc, "get_verse_of_day_notification_targets", new=AsyncMock(return_value=_targets([user]))):
result = await svc.dispatch_verse_of_day_notifications_service()

assert result.processed == 2
assert result.sent == 1
assert result.failed == 1

@pytest.mark.asyncio
@patch.object(svc, "get_bool", return_value=True)
@patch.object(svc, "is_push_configured", return_value=False)
@patch.object(svc, "send_verse_of_day_push_notification", new_callable=AsyncMock)
async def test_skips_devices_with_unconfigured_platform(self, mock_send, _is_configured, _get_bool):
user = _user(devices=[VerseOfDayPushDeviceTarget(token="token-1", platform="android")])
with patch.object(svc, "get_verse_of_day_notification_targets", new=AsyncMock(return_value=_targets([user]))):
result = await svc.dispatch_verse_of_day_notifications_service()

assert result.processed == 1
assert result.skipped == 1
assert result.sent == 0
mock_send.assert_not_awaited()
14 changes: 14 additions & 0 deletions worker_api/notifications/internal_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from worker_api.notifications.schemas import (
DispatchDueNotificationsResponse,
DispatchRoutineNotificationsResponse,
DispatchVerseOfDayNotificationsResponse,
RoutineNotificationTargetsResponse,
SendTestNotificationRequest,
SendTestNotificationResponse,
Expand All @@ -19,6 +20,9 @@
from worker_api.notifications.services.send_test_notification_service import (
send_test_notification_service,
)
from worker_api.notifications.services.verse_of_day_dispatch_service import (
dispatch_verse_of_day_notifications_service,
)

internal_router = APIRouter(prefix="/internal", tags=["Internal"])

Expand Down Expand Up @@ -53,6 +57,16 @@ async def dispatch_routine_notifications(
return await dispatch_routine_notifications_service()


@internal_router.post(
"/dispatch-verse-of-day-notifications",
status_code=status.HTTP_200_OK,
)
async def dispatch_verse_of_day_notifications(
_: None = Depends(verify_dispatch_token),
) -> DispatchVerseOfDayNotificationsResponse:
return await dispatch_verse_of_day_notifications_service()


@internal_router.post(
"/send-test-notification",
status_code=status.HTTP_200_OK,
Expand Down
Loading
Loading