From 67284dfe2e6938d1f5a98bd32043bd5c0613b3ee Mon Sep 17 00:00:00 2001 From: emi Date: Sun, 26 Jul 2026 18:27:40 +0000 Subject: [PATCH] bookmarks: account-synced saved locations API --- backend/accounts/api_accounts.py | 181 ++++++++++++++++++++++++++++++- 1 file changed, 178 insertions(+), 3 deletions(-) diff --git a/backend/accounts/api_accounts.py b/backend/accounts/api_accounts.py index 96f3276..6e83350 100644 --- a/backend/accounts/api_accounts.py +++ b/backend/accounts/api_accounts.py @@ -1,4 +1,4 @@ -"""Authenticated API for subscriptions and notifications. +"""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 @@ -7,7 +7,7 @@ doesn't leak which ids exist. Mounted under {BASE}/api/v2 alongside the rest of import os import time -from fastapi import APIRouter, Depends, HTTPException, Query, Request +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 @@ -17,8 +17,15 @@ 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 accounts.models import Bookmark, Notification, PushSubscription, Subscription from accounts.schemas import ( + BOOKMARK_MAX_PER_USER, + BookmarkImportIn, + BookmarkImportOut, + BookmarkIn, + BookmarkList, + BookmarkOut, + BookmarkPatch, NotificationList, NotificationOut, PushSubscriptionIn, @@ -323,3 +330,171 @@ async def push_test( # `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], + )