From 7ce352553d70ddfce6daff076b912651c493400f Mon Sep 17 00:00:00 2001 From: timmy Date: Tue, 11 Aug 2026 10:25:56 +0000 Subject: [PATCH] feat: defer Web Push updates until tomorrow (Closes #563) --- README.md | 4 +- frontend/service-worker.js | 35 +++++++++++++++- src/main.py | 55 +++++++++++++++++++++++++ tests/test_notification_later.py | 70 ++++++++++++++++++++++++++++++++ tests/test_service_worker.py | 54 ++++++++++++++++++++++-- 5 files changed, 213 insertions(+), 5 deletions(-) create mode 100644 tests/test_notification_later.py diff --git a/README.md b/README.md index 4266ab1..270ddc3 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,9 @@ export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts # Trust forwarding headers only from these immediate reverse-proxy networks. export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8' # Optional Web Push. Generate a VAPID key pair outside the repo and inject it. -# The feature stays disabled unless all three values are present. +# The feature stays disabled unless all three values are present. Privacy-safe update +# alerts offer Mark read and Tomorrow; Tomorrow syncs the unread item to Later at +# 09:00 in the device's local timezone without opening the dashboard. export STACKCHAIN_VAPID_PUBLIC_KEY='' export STACKCHAIN_VAPID_PRIVATE_KEY='' export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com' diff --git a/frontend/service-worker.js b/frontend/service-worker.js index d703175..c039ad3 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -289,7 +289,10 @@ self.addEventListener('push', event => { && route === '#/my-work/update/' + notificationId && tag === 'stackchain-update-' + notificationId ) { - options.actions = [{ action: 'mark-read', title: 'Mark read' }]; + options.actions = [ + { action: 'mark-read', title: 'Mark read' }, + { action: 'tomorrow', title: 'Tomorrow' }, + ]; options.data.notificationId = notificationId; } event.waitUntil(self.registration.showNotification('New work update', options)); @@ -306,6 +309,36 @@ async function openWorkRoute(route) { self.addEventListener('notificationclick', event => { const route = String(event.notification.data?.route || ''); + if (event.action === 'tomorrow') { + const notificationId = Number(event.notification.data?.notificationId); + if ( + !Number.isSafeInteger(notificationId) || notificationId <= 0 + || route !== '#/my-work/update/' + notificationId + || event.notification.tag !== 'stackchain-update-' + notificationId + ) return; + event.waitUntil((async () => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), PUSH_ACTION_TIMEOUT_MS); + try { + const wake = new Date(self.__STACKCHAIN_NOW?.() || Date.now()); + wake.setDate(wake.getDate() + 1); + wake.setHours(9, 0, 0, 0); + await fetchJson(BASE + 'api/v1/notifications/' + notificationId + '/later', { + method: 'PATCH', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ wake_at: wake.toISOString() }), + signal: controller.signal, + }); + event.notification.close(); + } catch (_error) { + await openWorkRoute(route); + event.notification.close(); + } finally { + clearTimeout(timeout); + } + })()); + return; + } if (event.action === 'mark-read') { const notificationId = Number(event.notification.data?.notificationId); if ( diff --git a/src/main.py b/src/main.py index e29fc24..c94f920 100644 --- a/src/main.py +++ b/src/main.py @@ -426,6 +426,10 @@ class LaterOperationBatch(BaseModel): operations: list[LaterOperation] = Field(min_length=1, max_length=50) +class NotificationLaterRequest(BaseModel): + wake_at: str = Field(min_length=1, max_length=100) + + class NotificationReply(BaseModel): body: str = Field(min_length=1, max_length=10_000) @@ -3216,6 +3220,57 @@ async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse: return JSONResponse({"id": thread_id, "status": "read"}) +@app.patch("/api/v1/notifications/{thread_id}/later") +async def defer_notification( + payload: NotificationLaterRequest, + thread_id: int = PathParam(gt=0), +) -> JSONResponse: + login = await _confirmed_login() + try: + item = await asyncio.wait_for( + gitea_proxy.resolve_work_route("update", None, None, thread_id), + timeout=NOTIFICATION_DETAIL_TIMEOUT_SECONDS, + ) + repository = item.get("repository") + if not isinstance(repository, str) or not repository: + raise ValueError("The update has no repository identity") + item_id = f"update:{repository}::{thread_id}" + result = await asyncio.to_thread( + _later_store().apply, + login, + f"push-tomorrow:{thread_id}:{payload.wake_at}", + "defer", + item_id, + wake_at=payload.wake_at, + ) + except gitea_proxy.WorkRouteUnavailableError: + return JSONResponse( + {"error": "This update is no longer available to defer."}, + status_code=409, + ) + except ValueError as error: + raise HTTPException(status_code=422, detail=str(error)) + except TimeoutError: + return JSONResponse( + {"error": "Deferring the update timed out. Please retry."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + except (OSError, sqlite3.Error): + return JSONResponse( + {"error": "Later synchronization is unavailable. Please retry."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + return JSONResponse({ + "id": thread_id, + "status": "deferred", + "item_id": item_id, + "wake_at": payload.wake_at, + "revision": result["revision"], + }) + + @app.post("/api/v1/notifications/{thread_id}/acknowledge") async def acknowledge_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse: try: diff --git a/tests/test_notification_later.py b/tests/test_notification_later.py new file mode 100644 index 0000000..0dde2d5 --- /dev/null +++ b/tests/test_notification_later.py @@ -0,0 +1,70 @@ +import httpx +import pytest + +from src import main + + +@pytest.mark.anyio +async def test_push_tomorrow_resolves_unread_update_and_persists_canonical_later_item( + monkeypatch, +): + resolved = [] + applied = [] + + async def confirmed_login(): + return "timmy" + + async def resolve(kind, repository, number, notification_id): + resolved.append((kind, repository, number, notification_id)) + return { + "kind": "update", + "notification_id": 42, + "repository": "stackchain/api", + "title": "Retry failed deploy", + } + + class Store: + def apply( + self, + login, + operation_id, + action, + item_id, + *, + wake_at=None, + base_revision=None, + ): + applied.append( + (login, operation_id, action, item_id, wake_at, base_revision) + ) + return {"revision": 3, "records": {item_id: wake_at}} + + monkeypatch.setattr(main, "_confirmed_login", confirmed_login) + monkeypatch.setattr(main.gitea_proxy, "resolve_work_route", resolve) + monkeypatch.setattr(main, "_later_store", lambda: Store()) + + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.patch( + "/api/v1/notifications/42/later", + json={"wake_at": "2026-08-12T09:00:00.000Z"}, + ) + + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert response.json() == { + "id": 42, + "status": "deferred", + "item_id": "update:stackchain/api::42", + "wake_at": "2026-08-12T09:00:00.000Z", + "revision": 3, + } + assert resolved == [("update", None, None, 42)] + assert applied == [( + "timmy", + "push-tomorrow:42:2026-08-12T09:00:00.000Z", + "defer", + "update:stackchain/api::42", + "2026-08-12T09:00:00.000Z", + None, + )] diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 419358e..5a9be1b 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -105,11 +105,11 @@ async function dispatchMessage(data, ports = []) {{ listeners.message({{ data, ports, waitUntil: promise => {{ pending = promise; }} }}); if (pending) await pending; }} -async function dispatchNotificationClick(route, action = '', notificationId = null) {{ +async function dispatchNotificationClick(route, action = '', notificationId = null, tag = null) {{ let pending; listeners.notificationclick({{ action, - notification: {{data: {{route, notificationId}}, close: () => {{ state.notificationClosed = true; }}}}, + notification: {{tag: tag || ('stackchain-update-' + notificationId), data: {{route, notificationId}}, close: () => {{ state.notificationClosed = true; }}}}, waitUntil: promise => {{ pending = promise; }}, }}); if (pending) await pending; @@ -401,7 +401,10 @@ def test_inbound_push_renders_generic_update_and_tap_opens_existing_workflow(): "options": { "body": "Tap to review it in Stackchain.", "tag": "stackchain-update-42", - "actions": [{"action": "mark-read", "title": "Mark read"}], + "actions": [ + {"action": "mark-read", "title": "Mark read"}, + {"action": "tomorrow", "title": "Tomorrow"}, + ], "data": { "route": "#/my-work/update/42", "notificationId": 42, @@ -453,6 +456,51 @@ def test_push_mark_read_action_confirms_authenticated_mutation_without_opening_a assert result["state"]["focused"] == [] +def test_push_tomorrow_action_defers_privately_without_opening_or_marking_read(): + result = run_worker_scenario( + """ + const calls = []; + context.self.__STACKCHAIN_NOW = () => new Date('2026-08-11T18:30:00.000Z'); + context.fetch = async (request, options = {}) => { + const url = String(request.url || request); + const headers = new Headers(options.headers || {}); + calls.push({ + url, method:String(options.method || 'GET'), csrf:headers.get('X-CSRF-Token'), + body:options.body ? JSON.parse(options.body) : null, + }); + if (url.endsWith('/api/v1/session')) { + return new Response(JSON.stringify({csrf_token:'session-proof'}), { + status:200, headers:{'Content-Type':'application/json'}, + }); + } + return new Response(JSON.stringify({id:42,status:'deferred'}), { + status:200, headers:{'Content-Type':'application/json'}, + }); + }; + await dispatchNotificationClick('#/my-work/update/42', 'tomorrow', 42, 'stackchain-update-42'); + process.stdout.write(JSON.stringify({state,calls})); +""" + ) + + assert result["calls"] == [ + { + "url": "https://forge.example/dashboard/api/v1/session", + "method": "GET", + "csrf": None, + "body": None, + }, + { + "url": "https://forge.example/dashboard/api/v1/notifications/42/later", + "method": "PATCH", + "csrf": "session-proof", + "body": {"wake_at": "2026-08-12T09:00:00.000Z"}, + }, + ] + assert result["state"]["notificationClosed"] is True + assert result["state"]["opened"] == [] + assert all(not call["url"].endswith("/read") for call in result["calls"]) + + def test_push_mark_read_failure_opens_existing_update_reader(): result = run_worker_scenario( """