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>
This commit is contained in:
2026-08-14 06:53:07 -06:00
co-authored by Claude Sonnet 5
parent aeb2f3f6a5
commit d09bf4a30a
27 changed files with 876 additions and 95 deletions
+35 -9
View File
@@ -1,9 +1,10 @@
# KeepItTalking backend (Phase 1 + 2)
# KeepItTalking backend (Phase 1 + 2 + 4)
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL. Implements auth, room CRUD
(open and private), room roles (owner/admin/member) and invites, and a
single-instance WebSocket chat endpoint. See `../ARCHITECTURE.md` for the
full system design and the phased build plan.
(open and private), room roles (owner/admin/member) and invites, a
single-instance WebSocket chat endpoint, and Web Push notifications for
offline room members. See `../ARCHITECTURE.md` for the full system design
and the phased build plan.
This is an **invite-only site**: there is no public registration endpoint.
Accounts are created by an operator on the app server — see step 4 below.
@@ -55,7 +56,17 @@ There's no public sign-up. Create accounts directly with the CLI (add
.venv/bin/python -m app.cli create-user alice alice@example.com "some-password"
```
### 5. Run the dev server
### 5. (Optional) Set up push notifications
Push works without any setup — `VAPID_PUBLIC_KEY`/`VAPID_PRIVATE_KEY` are
unset by default and push delivery is silently skipped. To enable it:
```bash
.venv/bin/python -m app.cli generate-vapid-keys
# paste the three printed lines into backend/.env
```
### 6. Run the dev server
```bash
.venv/bin/uvicorn app.main:app --reload
@@ -63,7 +74,7 @@ There's no public sign-up. Create accounts directly with the CLI (add
API docs: http://localhost:8000/docs. WebSocket chat endpoint: `ws://localhost:8000/ws/chat`.
### 6. Run tests
### 7. Run tests
Tests run against a real Postgres database (`chatapp_test` by default — native
`ENUM`/`UUID` types aren't faithfully reproduced by SQLite), with each test
@@ -82,17 +93,32 @@ app/
database.py async engine/session, get_db() dependency
dependencies.py get_current_user, require_room_member, require_room_role
security.py argon2 password hashing
cli.py `python -m app.cli create-user` (account provisioning)
cli.py `python -m app.cli create-user` / `generate-vapid-keys`
models/ SQLAlchemy models (users, rooms, room_memberships,
messages, room_invites)
messages, room_invites, push_subscriptions)
schemas/ Pydantic request/response models
routers/ auth, rooms, invites, health
routers/ auth, rooms, invites, push, health
services/ business logic called by routers
ws/ WebSocket connection manager + /ws/chat handler
alembic/ migrations
tests/ pytest + httpx/TestClient tests
```
## Push notifications (Phase 4)
`POST /api/push/subscribe` (upserts by `endpoint`) / `DELETE /api/push/subscribe`
manage a user's `push_subscriptions` rows; `GET /api/push/vapid-public-key` gives
the frontend the key it needs for `PushManager.subscribe()`. On every chat
message, `app/ws/chat.py` computes `room members - ConnectionManager.
connected_user_ids(room_id)` (who's actually connected to *that room* right
now, tracked alongside the existing WebSocket registry) and sends each
offline member a push via `pywebpush`, awaited inline against the same
request-scoped session rather than fired as a background task — the
broadcast to online members already happened by that point, so nothing
online-facing is delayed, and it sidesteps `asyncio.create_task()`s outliving
the session/event loop they were created on. An expired/invalid subscription
(pywebpush 404/410) is deleted automatically.
## Room roles and invites (Phase 2)
Rooms can be `open` (anyone can join via `POST /api/rooms/{id}/join`) or