Accounts Phase 3: save articles, account history, device import
- API (auth-required): GET/POST/DELETE /api/saved (+/api/saved/ids), GET/POST /api/history, POST /api/import — all FK-safe (skip ids that no longer exist). queries.saved/saved_ids/history reuse the feed article shape. - Frontend: reactive savedIds store (SvelteSet) + optimistic toggleSave; a Save control on cards for signed-in users; a "Saved" view (You sheet) with its own empty state; newly-seen items mirror to account history (cross-device); and a one-time import folds this device's anonymous history into the account on first sign-in. Anonymous browsing unchanged. 115 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -97,6 +97,13 @@ def _current_user(conn: sqlite3.Connection, request: Request) -> sqlite3.Row | N
|
||||
return user
|
||||
|
||||
|
||||
def _require_user(conn: sqlite3.Connection, request: Request) -> sqlite3.Row:
|
||||
user = _current_user(conn, request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Sign in to do that.")
|
||||
return user
|
||||
|
||||
|
||||
def _send_link_safe(email: str, link: str) -> None:
|
||||
"""Send the magic link, swallowing failures (runs off the request path)."""
|
||||
try:
|
||||
@@ -284,6 +291,15 @@ class SessionOut(BaseModel):
|
||||
token: str # for non-browser (app) clients; the web SPA uses the cookie
|
||||
|
||||
|
||||
class IdsBody(BaseModel):
|
||||
ids: list[int] = []
|
||||
|
||||
|
||||
class ImportBody(BaseModel):
|
||||
seen: list[int] = []
|
||||
saved: list[int] = []
|
||||
|
||||
|
||||
# --- App --------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -420,6 +436,86 @@ def create_app() -> FastAPI:
|
||||
ok.delete_cookie(OAUTH_COOKIE, path="/")
|
||||
return ok
|
||||
|
||||
# --- Saved articles, history, and one-time import (all require sign-in) ---
|
||||
|
||||
@app.get("/api/saved", response_model=FeedResponse)
|
||||
def saved_list(request: Request) -> FeedResponse:
|
||||
with get_conn() as conn:
|
||||
user = _require_user(conn, request)
|
||||
rows = queries.saved(conn, user["id"])
|
||||
items = [Article.from_row(r) for r in rows]
|
||||
return FeedResponse(topic=None, flavor=None, count=len(items), items=items)
|
||||
|
||||
@app.get("/api/saved/ids")
|
||||
def saved_id_list(request: Request) -> list[int]:
|
||||
with get_conn() as conn:
|
||||
user = _require_user(conn, request)
|
||||
return queries.saved_ids(conn, user["id"])
|
||||
|
||||
@app.post("/api/saved/{article_id}")
|
||||
def save_article(article_id: int, request: Request) -> dict:
|
||||
with get_conn() as conn:
|
||||
user = _require_user(conn, request)
|
||||
if not conn.execute("SELECT 1 FROM articles WHERE id = ?", (article_id,)).fetchone():
|
||||
raise HTTPException(status_code=404, detail="No such article.")
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO saved_articles (user_id, article_id) VALUES (?, ?)",
|
||||
(user["id"], article_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"saved": True}
|
||||
|
||||
@app.delete("/api/saved/{article_id}")
|
||||
def unsave_article(article_id: int, request: Request) -> dict:
|
||||
with get_conn() as conn:
|
||||
user = _require_user(conn, request)
|
||||
conn.execute(
|
||||
"DELETE FROM saved_articles WHERE user_id = ? AND article_id = ?",
|
||||
(user["id"], article_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"saved": False}
|
||||
|
||||
@app.get("/api/history", response_model=FeedResponse)
|
||||
def history_list(request: Request) -> FeedResponse:
|
||||
with get_conn() as conn:
|
||||
user = _require_user(conn, request)
|
||||
rows = queries.history(conn, user["id"])
|
||||
items = [Article.from_row(r) for r in rows]
|
||||
return FeedResponse(topic=None, flavor=None, count=len(items), items=items)
|
||||
|
||||
@app.post("/api/history")
|
||||
def record_history(body: IdsBody, request: Request) -> dict:
|
||||
with get_conn() as conn:
|
||||
user = _require_user(conn, request)
|
||||
for aid in queries.existing_article_ids(conn, body.ids):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO user_history (user_id, article_id, event) "
|
||||
"VALUES (?, ?, 'seen')",
|
||||
(user["id"], aid),
|
||||
)
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/api/import")
|
||||
def import_local(body: ImportBody, request: Request) -> dict:
|
||||
"""Fold this device's anonymous history/saved into the account (one-time)."""
|
||||
with get_conn() as conn:
|
||||
user = _require_user(conn, request)
|
||||
for aid in queries.existing_article_ids(conn, body.seen):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO user_history (user_id, article_id, event) "
|
||||
"VALUES (?, ?, 'seen')",
|
||||
(user["id"], aid),
|
||||
)
|
||||
for aid in queries.existing_article_ids(conn, body.saved):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO saved_articles (user_id, article_id) VALUES (?, ?)",
|
||||
(user["id"], aid),
|
||||
)
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/categories", response_model=CategoriesResponse)
|
||||
def categories() -> CategoriesResponse:
|
||||
return CategoriesResponse(
|
||||
|
||||
Reference in New Issue
Block a user