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:
jay
2026-06-03 12:56:31 +00:00
parent b635d8f574
commit 409bb11444
7 changed files with 329 additions and 22 deletions
+96
View File
@@ -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(
+54
View File
@@ -153,6 +153,60 @@ def brief(conn: sqlite3.Connection, brief_date: str | None = None, limit: int =
}
def saved(conn: sqlite3.Connection, user_id: int, limit: int = 200) -> list[dict]:
"""Articles a user has saved, newest first (same shape as the feed)."""
rows = conn.execute(
f"""
SELECT {_ARTICLE_COLUMNS}
FROM saved_articles sv
JOIN articles a ON a.id = sv.article_id
JOIN sources src ON src.id = a.source_id
LEFT JOIN article_scores s ON s.article_id = a.id
WHERE sv.user_id = ?
ORDER BY sv.saved_at DESC
LIMIT ?
""",
(user_id, limit),
).fetchall()
return [dict(row) for row in rows]
def saved_ids(conn: sqlite3.Connection, user_id: int) -> list[int]:
return [r[0] for r in conn.execute(
"SELECT article_id FROM saved_articles WHERE user_id = ?", (user_id,)
)]
def history(conn: sqlite3.Connection, user_id: int, limit: int = 200) -> list[dict]:
"""Articles in a user's account history, most-recent first."""
rows = conn.execute(
f"""
SELECT {_ARTICLE_COLUMNS}, MAX(h.at) AS seen_at
FROM user_history h
JOIN articles a ON a.id = h.article_id
JOIN sources src ON src.id = a.source_id
LEFT JOIN article_scores s ON s.article_id = a.id
WHERE h.user_id = ?
GROUP BY a.id
ORDER BY seen_at DESC
LIMIT ?
""",
(user_id, limit),
).fetchall()
return [dict(row) for row in rows]
def existing_article_ids(conn: sqlite3.Connection, ids: list[int]) -> list[int]:
"""Filter to ids that still exist (FK-safe inserts for save/history/import)."""
clean = list({int(i) for i in ids})[:1000]
if not clean:
return []
placeholders = ",".join("?" * len(clean))
return [r[0] for r in conn.execute(
f"SELECT id FROM articles WHERE id IN ({placeholders})", clean
)]
def tag_counts(conn: sqlite3.Connection, accepted_only: bool = True) -> dict:
"""How many shown (accepted, non-duplicate) articles carry each grouping tag."""
where = "WHERE a.duplicate_of IS NULL" + (" AND s.accepted = 1" if accepted_only else "")