diff --git a/docs/api-endpoints.md b/docs/api-endpoints.md index ad98f90..de728eb 100644 --- a/docs/api-endpoints.md +++ b/docs/api-endpoints.md @@ -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 | --- @@ -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. diff --git a/docs/notification-format.md b/docs/notification-format.md index fee2806..cc8d6d0 100644 --- a/docs/notification-format.md +++ b/docs/notification-format.md @@ -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: @@ -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`. @@ -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 @@ -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) | diff --git a/tests/notifications/test_backend_client.py b/tests/notifications/test_backend_client.py index 9a89a6f..d1d9c10 100644 --- a/tests/notifications/test_backend_client.py +++ b/tests/notifications/test_backend_client.py @@ -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): diff --git a/tests/notifications/test_notification_views.py b/tests/notifications/test_notification_views.py index 16bb392..2c5e596 100644 --- a/tests/notifications/test_notification_views.py +++ b/tests/notifications/test_notification_views.py @@ -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( diff --git a/tests/notifications/test_verse_of_day_dispatch_service.py b/tests/notifications/test_verse_of_day_dispatch_service.py new file mode 100644 index 0000000..7373bc6 --- /dev/null +++ b/tests/notifications/test_verse_of_day_dispatch_service.py @@ -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() diff --git a/worker_api/notifications/internal_views.py b/worker_api/notifications/internal_views.py index 0e41037..41ac929 100644 --- a/worker_api/notifications/internal_views.py +++ b/worker_api/notifications/internal_views.py @@ -5,6 +5,7 @@ from worker_api.notifications.schemas import ( DispatchDueNotificationsResponse, DispatchRoutineNotificationsResponse, + DispatchVerseOfDayNotificationsResponse, RoutineNotificationTargetsResponse, SendTestNotificationRequest, SendTestNotificationResponse, @@ -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"]) @@ -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, diff --git a/worker_api/notifications/schemas.py b/worker_api/notifications/schemas.py index c2cf9cf..dc01b94 100644 --- a/worker_api/notifications/schemas.py +++ b/worker_api/notifications/schemas.py @@ -200,3 +200,34 @@ class DeactivatePushDeviceRequest(BaseModel): class DeactivatePushDeviceResponse(BaseModel): push_device_id: UUID deactivated: bool + + +class VerseOfDayPushDeviceTarget(BaseModel): + token: str + platform: str + + +class VerseOfDayNotificationContent(BaseModel): + title: str + body: str + image_url: str | None = None + + +class VerseOfDayNotificationUserTarget(BaseModel): + user_id: UUID + notification: VerseOfDayNotificationContent + push_devices: list[VerseOfDayPushDeviceTarget] + + +class VerseOfDayNotificationTargetsResponse(BaseModel): + generated_at: datetime + users: list[VerseOfDayNotificationUserTarget] + + +class DispatchVerseOfDayNotificationsResponse(BaseModel): + generated_at: datetime + users: list[VerseOfDayNotificationUserTarget] + processed: int + sent: int + failed: int + skipped: int diff --git a/worker_api/notifications/services/backend_client.py b/worker_api/notifications/services/backend_client.py index 98610ce..286d519 100644 --- a/worker_api/notifications/services/backend_client.py +++ b/worker_api/notifications/services/backend_client.py @@ -8,6 +8,7 @@ DeactivatePushDeviceResponse, NotificationContent, RoutineNotificationTargetsResponse, + VerseOfDayNotificationTargetsResponse, ) @@ -66,6 +67,16 @@ async def fetch_chat_notification_targets( return ChatNotificationTargetsResponse.model_validate(response.json()) +async def fetch_verse_of_day_notification_targets() -> VerseOfDayNotificationTargetsResponse: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{_backend_url()}/internal/verse-of-day-notification-targets", + headers=_backend_headers(), + ) + response.raise_for_status() + return VerseOfDayNotificationTargetsResponse.model_validate(response.json()) + + async def deactivate_push_device(*, push_device_id: UUID) -> DeactivatePushDeviceResponse: async with httpx.AsyncClient(timeout=30.0) as client: response = await client.post( diff --git a/worker_api/notifications/services/push/fcm_client.py b/worker_api/notifications/services/push/fcm_client.py index 4cb87d7..45b74fb 100644 --- a/worker_api/notifications/services/push/fcm_client.py +++ b/worker_api/notifications/services/push/fcm_client.py @@ -83,6 +83,42 @@ async def send_routine_push_notification( ) +def build_verse_of_day_notification_data( + *, + title: str, + body: str, + image_url: str | None = None, +) -> dict[str, str]: + """FCM data payloads require string values.""" + return { + "notification_type": "VERSE_OF_DAY", + "session_type": "VERSE_OF_DAY", + "title": title, + "body": body, + "image_url": image_url or "", + } + + +async def send_verse_of_day_push_notification( + *, + device_token: str, + title: str, + body: str, + image_url: str | None = None, +) -> None: + await send_fcm_notification( + device_token=device_token, + title=title, + body=body, + image_url=image_url, + data=build_verse_of_day_notification_data( + title=title, + body=body, + image_url=image_url, + ), + ) + + async def send_chat_push_notification( *, device_token: str, diff --git a/worker_api/notifications/services/verse_of_day_dispatch_service.py b/worker_api/notifications/services/verse_of_day_dispatch_service.py new file mode 100644 index 0000000..40762d7 --- /dev/null +++ b/worker_api/notifications/services/verse_of_day_dispatch_service.py @@ -0,0 +1,69 @@ +import logging + +from worker_api.config import get_bool +from worker_api.notifications.schemas import DispatchVerseOfDayNotificationsResponse +from worker_api.notifications.services.push.config_loader import is_push_configured +from worker_api.notifications.services.push.fcm_client import ( + send_verse_of_day_push_notification, +) +from worker_api.notifications.services.verse_of_day_notification_service import ( + get_verse_of_day_notification_targets, +) + +logger = logging.getLogger(__name__) + + +async def dispatch_verse_of_day_notifications_service() -> DispatchVerseOfDayNotificationsResponse: + targets = await get_verse_of_day_notification_targets() + + if not get_bool("NOTIFICATION_DISPATCH_ENABLED"): + return DispatchVerseOfDayNotificationsResponse( + generated_at=targets.generated_at, + users=targets.users, + processed=0, + sent=0, + failed=0, + skipped=0, + ) + + processed = 0 + sent = 0 + failed = 0 + skipped = 0 + + for user in targets.users: + notification = user.notification + for device in user.push_devices: + processed += 1 + if not is_push_configured(device.platform): + logger.warning( + "Push not configured for platform %s; skipping user %s", + device.platform, + user.user_id, + ) + skipped += 1 + continue + + try: + await send_verse_of_day_push_notification( + device_token=device.token, + title=notification.title, + body=notification.body, + image_url=notification.image_url, + ) + sent += 1 + except Exception: + logger.exception( + "Failed to dispatch verse-of-day notification to user %s", + user.user_id, + ) + failed += 1 + + return DispatchVerseOfDayNotificationsResponse( + generated_at=targets.generated_at, + users=targets.users, + processed=processed, + sent=sent, + failed=failed, + skipped=skipped, + ) diff --git a/worker_api/notifications/services/verse_of_day_notification_service.py b/worker_api/notifications/services/verse_of_day_notification_service.py new file mode 100644 index 0000000..ed40f93 --- /dev/null +++ b/worker_api/notifications/services/verse_of_day_notification_service.py @@ -0,0 +1,6 @@ +from worker_api.notifications.schemas import VerseOfDayNotificationTargetsResponse +from worker_api.notifications.services.backend_client import fetch_verse_of_day_notification_targets + + +async def get_verse_of_day_notification_targets() -> VerseOfDayNotificationTargetsResponse: + return await fetch_verse_of_day_notification_targets()