Follow source/topic — account-backed personalization (v1)

Per Codex — turn accounts into a real reason to return, without an algorithmic
feed. Durable interests (sources + tags), not moods.

* DB: user_follows (user_id, kind source|tag, value, unique).
* queries.feed gains follow_sources/follow_tags → the Following feed is
  "articles from a followed source OR carrying a followed tag", still respecting
  calm filters/boundaries.
* API: GET/POST/DELETE /api/follows (sign-in required; source ids validated);
  /api/feed?following=true resolves the user's follows (anon → empty, not error).
* Frontend: follows store (followKeys + toggleFollow, mirrors savedIds); a
  Follow button on source + tag/topic views; a "Following" lane in the nav with
  a tailored empty state; a Following management section in Account (unfollow).

Digest "From what you follow" deferred to v2 (brief stays first).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-09 17:34:46 -04:00
parent 69ed202c4e
commit d8e246b4ff
7 changed files with 267 additions and 12 deletions
+72
View File
@@ -415,6 +415,11 @@ class DigestBody(BaseModel):
enabled: bool = True
class FollowBody(BaseModel):
kind: str # 'source' | 'tag'
value: str # source id (as text) or tag key
class SourceReviewBody(BaseModel):
flag: bool = False
reason: str | None = None
@@ -640,6 +645,62 @@ def create_app() -> FastAPI:
user = _require_user(conn, request)
return queries.saved_ids(conn, user["id"])
# --- Follows: durable source / tag interests (require sign-in) ---
def _follows_for(conn, user_id: int) -> list[dict]:
rows = conn.execute(
"SELECT kind, value FROM user_follows WHERE user_id = ? ORDER BY created_at DESC", (user_id,)
).fetchall()
out = []
for r in rows:
d = {"kind": r["kind"], "value": r["value"], "name": r["value"]}
if r["kind"] == "source" and r["value"].isdigit():
src = conn.execute("SELECT name FROM sources WHERE id = ?", (int(r["value"]),)).fetchone()
if src:
d["name"] = src["name"]
out.append(d)
return out
@app.get("/api/follows")
def follows_list(request: Request) -> list[dict]:
with get_conn() as conn:
user = _require_user(conn, request)
return _follows_for(conn, user["id"])
@app.post("/api/follows")
def follow_add(body: FollowBody, request: Request) -> dict:
if body.kind not in ("source", "tag"):
raise HTTPException(status_code=422, detail="kind must be 'source' or 'tag'")
value = (body.value or "").strip()
if body.kind == "tag":
value = value.lower()
if not value:
raise HTTPException(status_code=422, detail="value is required")
with get_conn() as conn:
user = _require_user(conn, request)
if body.kind == "source":
if not value.isdigit() or not conn.execute(
"SELECT 1 FROM sources WHERE id = ?", (int(value),)
).fetchone():
raise HTTPException(status_code=404, detail="source not found")
conn.execute(
"INSERT OR IGNORE INTO user_follows (user_id, kind, value) VALUES (?, ?, ?)",
(user["id"], body.kind, value),
)
conn.commit()
return {"ok": True, "kind": body.kind, "value": value}
@app.delete("/api/follows")
def follow_remove(request: Request, kind: str = Query(...), value: str = Query(...)) -> dict:
v = value.strip().lower() if kind == "tag" else value.strip()
with get_conn() as conn:
user = _require_user(conn, request)
conn.execute(
"DELETE FROM user_follows WHERE user_id = ? AND kind = ? AND value = ?", (user["id"], kind, v)
)
conn.commit()
return {"ok": True}
@app.post("/api/saved/{article_id}")
def save_article(article_id: int, request: Request) -> dict:
with get_conn() as conn:
@@ -1309,6 +1370,8 @@ def create_app() -> FastAPI:
tag: str | None = Query(None, description="grouping tag to browse"),
source_id: int | None = Query(None, ge=1, description="show only this source's articles"),
sort: str = Query("ranked", pattern="^(ranked|latest)$", description="ranked (best-first) or latest (newest-first)"),
following: bool = Query(False, description="restrict to the signed-in user's followed sources/tags"),
request: Request = None,
) -> FeedResponse:
if topic and topic.lower() not in TOPICS:
raise HTTPException(400, f"unknown topic: {topic}")
@@ -1322,6 +1385,15 @@ def create_app() -> FastAPI:
# word-boundary avoid-terms and dismissals need a Python pass.
kw = _prefs_sql_kw(fp, now)
with get_conn() as conn:
if following:
user = _current_user(conn, request)
if not user:
return FeedResponse(topic=topic, flavor=flavor, count=0, items=[])
frows = conn.execute(
"SELECT kind, value FROM user_follows WHERE user_id = ?", (user["id"],)
).fetchall()
kw["follow_sources"] = [int(r["value"]) for r in frows if r["kind"] == "source" and r["value"].isdigit()]
kw["follow_tags"] = [r["value"] for r in frows if r["kind"] == "tag"]
if fp.avoid_terms or excl:
# Over-fetch enough to cover what the Python pass might remove.
fetch_n = min(2000, (offset + limit) * 4 + 50 + len(excl))