"""Authenticated API for subscriptions and notifications. All routes require a logged-in user (the fastapi-users cookie session) and are scoped to that user — a row that isn't theirs reads as 404, never 403, so the API doesn't leak which ids exist. Mounted under {BASE}/api/v2 alongside the rest of v2. """ import os import time from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.concurrency import run_in_threadpool from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from data import climate from data import grid from notifications import push from accounts.db import get_async_session, get_read_session from accounts.models import Notification, PushSubscription, Subscription from web.schemas import ( NotificationList, NotificationOut, PushSubscriptionIn, PushUnsubscribeIn, SubscriptionIn, SubscriptionOut, SubscriptionPatch, ) from accounts.users import current_active_user _BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/") BASE = f"/{_BASE}" if _BASE else "" router = APIRouter(tags=["accounts"]) async def _owned_subscription(session: AsyncSession, sub_id: int, user) -> Subscription: sub = ( await session.execute( select(Subscription).where( Subscription.id == sub_id, Subscription.user_id == user.id ) ) ).scalar_one_or_none() if sub is None: raise HTTPException(status_code=404, detail="Subscription not found.") return sub # --- subscriptions ----------------------------------------------------------- @router.get("/subscriptions", response_model=list[SubscriptionOut]) async def list_subscriptions( user=Depends(current_active_user), session: AsyncSession = Depends(get_read_session), # pure read → read-only engine ): rows = ( await session.execute( select(Subscription) .where(Subscription.user_id == user.id) .order_by(Subscription.created_at) ) ).scalars().all() return rows @router.post("/subscriptions", response_model=SubscriptionOut, status_code=201) async def create_subscription( body: SubscriptionIn, user=Depends(current_active_user), session: AsyncSession = Depends(get_async_session), ): cell = grid.snap(body.lat, body.lon) cell_id = cell["id"] dup = ( await session.execute( select(Subscription).where( Subscription.user_id == user.id, Subscription.cell_id == cell_id, Subscription.kind == body.kind, ) ) ).scalar_one_or_none() if dup is not None: raise HTTPException( status_code=409, detail=f"You already have a {body.kind} alert for this location.", ) label = body.label if not label: # Cache-only lookup — never block the event loop on Nominatim. The frontend # normally supplies the label from /api/v2/place; this is just a fallback. _, label = climate.reverse_geocode_cached(cell["center_lat"], cell["center_lon"]) sub = Subscription( user_id=user.id, cell_id=cell_id, label=label, lat=body.lat, lon=body.lon, threshold=body.threshold, metrics=body.metrics, kind=body.kind, two_sided=body.two_sided, ) session.add(sub) await session.commit() await session.refresh(sub) return sub @router.patch("/subscriptions/{sub_id}", response_model=SubscriptionOut) async def update_subscription( sub_id: int, body: SubscriptionPatch, user=Depends(current_active_user), session: AsyncSession = Depends(get_async_session), ): sub = await _owned_subscription(session, sub_id, user) for key, value in body.model_dump(exclude_unset=True).items(): setattr(sub, key, value) await session.commit() await session.refresh(sub) return sub @router.delete("/subscriptions/{sub_id}", status_code=204) async def delete_subscription( sub_id: int, user=Depends(current_active_user), session: AsyncSession = Depends(get_async_session), ): sub = await _owned_subscription(session, sub_id, user) await session.delete(sub) await session.commit() # --- notifications ----------------------------------------------------------- @router.get("/notifications", response_model=NotificationList) async def list_notifications( unread: bool = Query(False, description="only return unread notifications"), limit: int = Query(50, ge=1, le=200), user=Depends(current_active_user), session: AsyncSession = Depends(get_read_session), # pure read → read-only engine ): q = select(Notification).where(Notification.user_id == user.id) if unread: q = q.where(Notification.read_at.is_(None)) q = q.order_by(Notification.created_at.desc()).limit(limit) rows = (await session.execute(q)).scalars().all() unread_count = ( await session.execute( select(func.count()) .select_from(Notification) .where(Notification.user_id == user.id, Notification.read_at.is_(None)) ) ).scalar_one() return NotificationList( notifications=[NotificationOut.model_validate(r) for r in rows], unread_count=unread_count, ) @router.post("/notifications/{notif_id}/read", status_code=204) async def mark_notification_read( notif_id: int, user=Depends(current_active_user), session: AsyncSession = Depends(get_async_session), ): import time notif = ( await session.execute( select(Notification).where( Notification.id == notif_id, Notification.user_id == user.id ) ) ).scalar_one_or_none() if notif is None: raise HTTPException(status_code=404, detail="Notification not found.") if notif.read_at is None: notif.read_at = time.time() await session.commit() @router.post("/notifications/read-all", status_code=204) async def mark_all_read( user=Depends(current_active_user), session: AsyncSession = Depends(get_async_session), ): rows = ( await session.execute( select(Notification).where( Notification.user_id == user.id, Notification.read_at.is_(None) ) ) ).scalars().all() now = time.time() for n in rows: n.read_at = now if rows: await session.commit() # --- web push ----------------------------------------------------------------- # The VAPID public key is not a secret and the browser needs it before it can # subscribe, so this one route is open (still under BASE). Everything else is # user-scoped like the subscription routes above. @router.get("/push/vapid-key") async def push_vapid_key(): return {"key": push.public_key()} @router.post("/push/subscribe", status_code=201) async def push_subscribe( body: PushSubscriptionIn, request: Request, user=Depends(current_active_user), session: AsyncSession = Depends(get_async_session), ): """Register (or refresh) this device's Web Push endpoint for the user. Keyed on `endpoint` — re-subscribing from the same device updates the keys in place rather than creating a duplicate, and a device that was registered to a different account is re-homed to the current user. """ now = time.time() existing = ( await session.execute( select(PushSubscription).where(PushSubscription.endpoint == body.endpoint) ) ).scalar_one_or_none() if existing is not None: existing.user_id = user.id existing.p256dh = body.keys.p256dh existing.auth = body.keys.auth existing.user_agent = request.headers.get("user-agent") existing.last_used_at = now else: session.add( PushSubscription( user_id=user.id, endpoint=body.endpoint, p256dh=body.keys.p256dh, auth=body.keys.auth, user_agent=request.headers.get("user-agent"), created_at=now, last_used_at=now, ) ) await session.commit() return {"ok": True} @router.delete("/push/subscribe", status_code=204) async def push_unsubscribe( body: PushUnsubscribeIn, user=Depends(current_active_user), session: AsyncSession = Depends(get_async_session), ): """Drop this device's endpoint (called on toggle-off / permission revoked).""" row = ( await session.execute( select(PushSubscription).where( PushSubscription.endpoint == body.endpoint, PushSubscription.user_id == user.id, ) ) ).scalar_one_or_none() if row is not None: await session.delete(row) await session.commit() @router.post("/push/test", status_code=202) async def push_test( user=Depends(current_active_user), session: AsyncSession = Depends(get_async_session), ): """Send a canned push to every device registered to the user — the on-device delivery check. Prunes any endpoint the push service reports as gone.""" rows = ( await session.execute( select(PushSubscription).where(PushSubscription.user_id == user.id) ) ).scalars().all() payload = { "title": "Thermograph", "body": "Push notifications are working on this device.", "url": f"{BASE}/alerts", "tag": "thermograph-test", } sent, pruned, failed = 0, 0, 0 for row in rows: info = {"endpoint": row.endpoint, "keys": {"p256dh": row.p256dh, "auth": row.auth}} # pywebpush blocks on network I/O — keep it off the event loop. result = await run_in_threadpool(push.send, info, payload) if result == "ok": sent += 1 elif result == "gone": await session.delete(row) pruned += 1 else: failed += 1 if pruned: await session.commit() # `failed` (delivery rejected — e.g. VAPID key mismatch) is surfaced so the client # can tell "request accepted" apart from "actually delivered". return {"devices": len(rows), "sent": sent, "pruned": pruned, "failed": failed}