Files
ds-chat/backend/app/services/push_service.py
T
ksmithandClaude Sonnet 5 d09bf4a30a Phase 4: Web Push notifications (pywebpush + VAPID)
Backend: PushSubscription model/migration, VAPID config + `cli.py
generate-vapid-keys`, push_service.send_push_to_user (upsert-by-endpoint
subscribe/unsubscribe, auto-cleanup of expired 404/410 subscriptions),
/api/push/* router, and ConnectionManager now tracks connected user IDs
per room so chat.py can push only to offline members after broadcasting
to online ones.

Two test-infra bugs found and fixed along the way: send_push_to_user
takes the caller's AsyncSession and is awaited inline rather than fired
via asyncio.create_task with its own session (background tasks were
outliving the test event loop); and the ws_client fixture now uses
NullPool to eliminate a connection-pool checkout race that was failing
WS tests intermittently.

Frontend: service worker rebuilt with vite-plugin-pwa's injectManifest
strategy (custom src/sw.ts) so it can add push/notificationclick
handlers alongside the existing precaching and StaleWhileRevalidate
routes ported over from generateSW. New subscribe/unsubscribe flow
(lib/push.ts, api/push.ts) with a toggle in the account menu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 06:53:07 -06:00

99 lines
3.6 KiB
Python

import asyncio
import json
import logging
import uuid
from pywebpush import WebPushException, webpush
from sqlalchemy import delete, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models import PushSubscription
from app.schemas.push import PushSubscriptionCreate
logger = logging.getLogger(__name__)
async def subscribe(
db: AsyncSession, user_id: uuid.UUID, data: PushSubscriptionCreate
) -> PushSubscription:
# Upsert by endpoint: the same device/browser re-subscribing (e.g. after
# a key rotation, or logging in as someone else on a shared device)
# updates the existing row rather than erroring on the unique constraint.
stmt = (
pg_insert(PushSubscription)
.values(
user_id=user_id,
endpoint=data.endpoint,
p256dh_key=data.keys.p256dh,
auth_key=data.keys.auth,
)
.on_conflict_do_update(
index_elements=[PushSubscription.endpoint],
set_={
"user_id": user_id,
"p256dh_key": data.keys.p256dh,
"auth_key": data.keys.auth,
},
)
.returning(PushSubscription)
)
result = await db.execute(stmt)
await db.commit()
return result.scalar_one()
async def unsubscribe(db: AsyncSession, user_id: uuid.UUID, endpoint: str) -> None:
await db.execute(
delete(PushSubscription).where(
PushSubscription.user_id == user_id, PushSubscription.endpoint == endpoint
)
)
await db.commit()
def _send_one(subscription: PushSubscription, payload: dict) -> None:
webpush(
subscription_info={
"endpoint": subscription.endpoint,
"keys": {"p256dh": subscription.p256dh_key, "auth": subscription.auth_key},
},
data=json.dumps(payload),
vapid_private_key=settings.vapid_private_key,
vapid_claims={"sub": settings.vapid_subject},
)
async def send_push_to_user(db: AsyncSession, user_id: uuid.UUID, payload: dict) -> None:
"""Called (awaited) from the WS handler after broadcasting to connected
clients, so it never delays delivery to anyone actually online. Runs
sequentially against the caller's session rather than firing background
asyncio.create_task()s -- those can easily outlive the request/test event
loop they were created on, and AsyncSession isn't safe to touch from two
coroutines concurrently, so a fire-and-forget task per subscription would
risk exactly that. Each webpush() call itself still runs off the event
loop via asyncio.to_thread (pywebpush is synchronous)."""
if not settings.vapid_private_key:
logger.debug("VAPID keys not configured; skipping push to %s", user_id)
return
result = await db.execute(
select(PushSubscription).where(PushSubscription.user_id == user_id)
)
subscriptions = list(result.scalars().all())
for subscription in subscriptions:
try:
await asyncio.to_thread(_send_one, subscription, payload)
except WebPushException as exc:
status = exc.response.status_code if exc.response is not None else None
if status in (404, 410):
# Subscription is gone (browser unsubscribed, expired, etc.)
await db.execute(
delete(PushSubscription).where(PushSubscription.id == subscription.id)
)
await db.commit()
else:
logger.warning("Push delivery failed for %s: %s", subscription.id, exc)