"""Authenticated API for subscriptions, notifications, and bookmarks. 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, Response from fastapi.concurrency import run_in_threadpool from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from core import audit 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 Bookmark, Notification, PushSubscription, Subscription from accounts.schemas import ( BOOKMARK_MAX_PER_USER, BookmarkImportIn, BookmarkImportOut, BookmarkIn, BookmarkList, BookmarkOut, BookmarkPatch, 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. # Still a sync DB read under the hood, so keep it off the event loop too. _, label = await run_in_threadpool( 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) audit.log_activity("sub.create", {"user_id": str(user.id), "sub_id": sub.id, "cell_id": cell_id, "kind": body.kind}) 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) changed = body.model_dump(exclude_unset=True) for key, value in changed.items(): setattr(sub, key, value) await session.commit() await session.refresh(sub) audit.log_activity("sub.update", {"user_id": str(user.id), "sub_id": sub.id, "changed": list(changed.keys())}) 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) kind = sub.kind await session.delete(sub) await session.commit() audit.log_activity("sub.delete", {"user_id": str(user.id), "sub_id": sub_id, "kind": kind}) # --- 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() audit.log_activity("push.subscribe", {"user_id": str(user.id), "refreshed": existing is not None}) 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() audit.log_activity("push.unsubscribe", {"user_id": str(user.id)}) @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} # --- bookmarks ----------------------------------------------------------------- async def _owned_bookmark(session: AsyncSession, bookmark_id: int, user) -> Bookmark: bookmark = ( await session.execute( select(Bookmark).where( Bookmark.id == bookmark_id, Bookmark.user_id == user.id ) ) ).scalar_one_or_none() if bookmark is None: raise HTTPException(status_code=404, detail="Bookmark not found.") return bookmark @router.get("/bookmarks", response_model=BookmarkList) async def list_bookmarks( user=Depends(current_active_user), session: AsyncSession = Depends(get_read_session), # pure read → read-only engine ): rows = ( await session.execute( select(Bookmark) .where(Bookmark.user_id == user.id) .order_by(Bookmark.created_at) ) ).scalars().all() return BookmarkList(bookmarks=[BookmarkOut.model_validate(r) for r in rows]) @router.post("/bookmarks", response_model=BookmarkOut, status_code=201) async def create_bookmark( body: BookmarkIn, response: Response, user=Depends(current_active_user), session: AsyncSession = Depends(get_async_session), ): """Create a bookmark, or — if one already exists for this (user, cell) — rename it in place and return 200. Idempotent upsert rather than a 409, so re-bookmarking a place you already saved is never an error.""" cell = grid.snap(body.lat, body.lon) cell_id = cell["id"] existing = ( await session.execute( select(Bookmark).where( Bookmark.user_id == user.id, Bookmark.cell_id == cell_id ) ) ).scalar_one_or_none() if existing is not None: existing.label = body.label await session.commit() await session.refresh(existing) audit.log_activity("bookmark.update", {"user_id": str(user.id), "bookmark_id": existing.id}) response.status_code = 200 return existing count = ( await session.execute( select(func.count()).select_from(Bookmark).where(Bookmark.user_id == user.id) ) ).scalar_one() if count >= BOOKMARK_MAX_PER_USER: raise HTTPException( status_code=409, detail=f"You can have at most {BOOKMARK_MAX_PER_USER} bookmarks.", ) bookmark = Bookmark( user_id=user.id, cell_id=cell_id, label=body.label, lat=body.lat, lon=body.lon, ) session.add(bookmark) await session.commit() await session.refresh(bookmark) audit.log_activity("bookmark.create", {"user_id": str(user.id), "bookmark_id": bookmark.id, "cell_id": cell_id}) return bookmark @router.patch("/bookmarks/{bookmark_id}", response_model=BookmarkOut) async def update_bookmark( bookmark_id: int, body: BookmarkPatch, user=Depends(current_active_user), session: AsyncSession = Depends(get_async_session), ): bookmark = await _owned_bookmark(session, bookmark_id, user) bookmark.label = body.label await session.commit() await session.refresh(bookmark) audit.log_activity("bookmark.rename", {"user_id": str(user.id), "bookmark_id": bookmark.id}) return bookmark @router.delete("/bookmarks/{bookmark_id}", status_code=204) async def delete_bookmark( bookmark_id: int, user=Depends(current_active_user), session: AsyncSession = Depends(get_async_session), ): bookmark = await _owned_bookmark(session, bookmark_id, user) await session.delete(bookmark) await session.commit() audit.log_activity("bookmark.delete", {"user_id": str(user.id), "bookmark_id": bookmark_id}) @router.post("/bookmarks/import", response_model=BookmarkImportOut) async def import_bookmarks( body: BookmarkImportIn, user=Depends(current_active_user), session: AsyncSession = Depends(get_async_session), ): """Bulk-import a visitor's localStorage bookmarks at login. Existing rows keep their label (never overwritten); duplicates within the payload itself are also skipped. Stops importing once the user's total hits BOOKMARK_MAX_PER_USER — any remaining items count as skipped rather than erroring.""" existing_rows = ( await session.execute(select(Bookmark).where(Bookmark.user_id == user.id)) ).scalars().all() existing_cells = {r.cell_id for r in existing_rows} count = len(existing_rows) imported = 0 skipped = 0 seen_cells: set[str] = set() for item in body.items: cell_id = grid.snap(item.lat, item.lon)["id"] if cell_id in existing_cells or cell_id in seen_cells: skipped += 1 continue if count >= BOOKMARK_MAX_PER_USER: skipped += 1 continue session.add(Bookmark( user_id=user.id, cell_id=cell_id, label=item.label, lat=item.lat, lon=item.lon, )) seen_cells.add(cell_id) count += 1 imported += 1 if imported: await session.commit() audit.log_activity("bookmark.import", {"user_id": str(user.id), "imported": imported, "skipped": skipped}) rows = ( await session.execute( select(Bookmark) .where(Bookmark.user_id == user.id) .order_by(Bookmark.created_at) ) ).scalars().all() return BookmarkImportOut( imported=imported, skipped=skipped, bookmarks=[BookmarkOut.model_validate(r) for r in rows], )