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
+19
View File
@@ -62,6 +62,8 @@ def feed(
tag: str | None = None,
source_id: int | None = None,
sort: str = "ranked",
follow_sources: list[int] | None = None,
follow_tags: list[str] | None = None,
) -> list[dict]:
"""Return articles with categorical filters applied in SQL.
@@ -114,6 +116,23 @@ def feed(
clauses.append("a.source_id = ?")
params.append(source_id)
# "Following" feed: articles from a followed source OR carrying a followed tag.
# Passing either list (even empty) switches to following mode; no follows → none.
if follow_sources is not None or follow_tags is not None:
ors: list[str] = []
for sid in follow_sources or []:
params.append(sid)
if follow_sources:
ors.append(f"a.source_id IN ({','.join('?' * len(follow_sources))})")
ftags = [t.lower() for t in (follow_tags or [])]
if ftags:
ors.append(
f"EXISTS (SELECT 1 FROM article_tags at WHERE at.article_id = a.id "
f"AND at.tag IN ({','.join('?' * len(ftags))}))"
)
params.extend(ftags)
clauses.append("(" + " OR ".join(ors) + ")" if ors else "0")
where = "WHERE " + " AND ".join(clauses)
params.extend([limit, offset])