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
+30
View File
@@ -235,3 +235,33 @@ def test_digest_toggle_and_unsubscribe(tmp_path, monkeypatch):
assert TestClient(app).post(f"/api/digest/unsubscribe?u={uid}&t={tok}").json() == {"ok": True}
assert tc.get("/api/auth/me").json()["digest_enabled"] is False
assert TestClient(app).post("/api/account/digest", json={"enabled": True}).status_code == 401 # gated
def test_follows_and_following_feed(tmp_path, monkeypatch):
import os, sqlite3
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
c = sqlite3.connect(os.environ["GOODNEWS_DB"])
c.execute("INSERT INTO sources (id,name,feed_url) VALUES (2,'Other','http://o/f')")
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash) VALUES (2,2,'http://o/2','t2','h2')")
c.execute("INSERT INTO article_scores (article_id,accepted,topic) VALUES (2,1,'tech')")
c.commit(); c.close()
tc = _signin(app, api, "reader@x.com")
# no follows yet → empty following feed (not an error)
assert tc.get("/api/feed?following=true").json()["count"] == 0
# follow source 1 → only its article
assert tc.post("/api/follows", json={"kind": "source", "value": "1"}).json()["ok"] is True
assert any(x["value"] == "1" and x["name"] == "S" for x in tc.get("/api/follows").json())
assert [i["id"] for i in tc.get("/api/feed?following=true").json()["items"]] == [1]
# follow source 2 too → both
tc.post("/api/follows", json={"kind": "source", "value": "2"})
assert {i["id"] for i in tc.get("/api/feed?following=true").json()["items"]} == {1, 2}
# follow tag works too (article 1 carries 'science')
tc.post("/api/follows", json={"kind": "tag", "value": "Science"}) # normalized lower
assert any(x["kind"] == "tag" and x["value"] == "science" for x in tc.get("/api/follows").json())
# unfollow source 2 (DELETE via query) → back to {1}
tc.delete("/api/follows?kind=source&value=2")
assert {i["id"] for i in tc.get("/api/feed?following=true").json()["items"]} == {1}
# anon: following feed empty, follows API gated, bad source 404
assert TestClient(app).get("/api/feed?following=true").json()["count"] == 0
assert TestClient(app).get("/api/follows").status_code == 401
assert tc.post("/api/follows", json={"kind": "source", "value": "999"}).status_code == 404