diff --git a/apps/web/src/app/FollowControls.jsx b/apps/web/src/app/FollowControls.jsx index 4769aec..e4ff3bf 100644 --- a/apps/web/src/app/FollowControls.jsx +++ b/apps/web/src/app/FollowControls.jsx @@ -20,7 +20,7 @@ import FollowButton from './FollowButton.jsx'; * * @param {{ * endpoint: string, - * kind: 'feed'|'topic', + * kind: 'feed'|'topic'|'author', * slug: string, * segment?: string, * following: boolean, @@ -81,7 +81,7 @@ export default function FollowControls({ * this only says whether this one follow feeds them. * * @param {{ - * kind: 'feed'|'topic', + * kind: 'feed'|'topic'|'author', * slug: string, * segment?: string, * alerts: boolean, diff --git a/apps/web/src/app/account/alerts/page.jsx b/apps/web/src/app/account/alerts/page.jsx index 11a903c..6ab85aa 100644 --- a/apps/web/src/app/account/alerts/page.jsx +++ b/apps/web/src/app/account/alerts/page.jsx @@ -13,7 +13,8 @@ export const dynamic = 'force-dynamic'; export const metadata = { title: 'Alerts', - description: 'Where you are told about new posts from the blogs and topics you follow.', + description: + 'Where you are told about new posts from the blogs, topics and people you follow.', }; /** What each error code from /api/alerts/channels means to a reader. */ @@ -52,7 +53,8 @@ export default async function AlertsPage({ searchParams }) { ]); const hasEmail = channels.some((c) => c.kind === 'email'); - const watching = following.feeds.length + following.topics.length; + const watching = + following.feeds.length + following.topics.length + following.authors.length; return ( <> @@ -150,7 +152,8 @@ export default async function AlertsPage({ searchParams }) { {watching === 0 ? (

Nothing yet. Open a blog you follow โ€” or any{' '} - topic โ€” and press ๐Ÿ”” beside the Follow button. + topic or person โ€” and press ๐Ÿ”” beside the + Follow button.

) : ( <> @@ -167,6 +170,16 @@ export default async function AlertsPage({ searchParams }) { )} + {following.authors.length > 0 && ( +
+ {following.authors.map((a) => ( + + {String(a.name)} + + ))} +
+ )} + {following.feeds.length > 0 && (
{following.feeds.map((f) => ( diff --git a/apps/web/src/app/api/alerts/route.js b/apps/web/src/app/api/alerts/route.js index 41a21ef..bd312f3 100644 --- a/apps/web/src/app/api/alerts/route.js +++ b/apps/web/src/app/api/alerts/route.js @@ -1,4 +1,4 @@ -import { alerts, q } from '@rssamplifier/db'; +import { alerts, authors, q } from '@rssamplifier/db'; import { db } from '../../../lib/db.js'; import { currentUser } from '../../../lib/auth.js'; @@ -52,7 +52,9 @@ export async function POST(req) { return json({ error: 'bad-request' }, 400); } - if (kind !== 'feed' && kind !== 'topic') return json({ error: 'bad-kind' }, 400); + if (kind !== 'feed' && kind !== 'topic' && kind !== 'author') { + return json({ error: 'bad-kind' }, 400); + } // Only ever back to somewhere on this site. `next` arrives in a form field, so // an absolute URL in it would make this endpoint an open redirect. @@ -69,7 +71,9 @@ export async function POST(req) { const changed = kind === 'feed' ? await setForFeed(client, userId, slug, on) - : await alerts.setTopicAlerts(client, userId, slugFromUrl(slug), segment, on); + : kind === 'author' + ? await setForAuthor(client, userId, slug, on) + : await alerts.setTopicAlerts(client, userId, slugFromUrl(slug), segment, on); // Not following it โ€” or, for a blog, no such blog. Either way there is nothing // to flag, and saying so is more useful than reporting a success that did not @@ -98,6 +102,24 @@ async function setForFeed(client, userId, slug, on) { return alerts.setFeedAlerts(client, userId, String(feed.id), on); } +/** + * Resolve a person's slug to their id, then flag the follow. + * + * The same indirection as the blog above and for the same reason: the table is + * keyed on an id the reader never sees, and the slug is what a page can send. + * + * @param {import('@libsql/client').Client} client + * @param {string} userId + * @param {string} slug + * @param {boolean} on + * @returns {Promise} + */ +async function setForAuthor(client, userId, slug, on) { + const person = await authors.authorBySlug(client, String(slug).trim().toLowerCase()); + if (!person) return false; + return alerts.setAuthorAlerts(client, userId, String(person.id), on); +} + /** * Where a no-JavaScript submit lands when the form did not say. * @@ -107,6 +129,7 @@ async function setForFeed(client, userId, slug, on) { * @returns {string} */ function fallbackPath(kind, slug, segment) { + if (kind === 'author') return `/authors/${encodeURIComponent(String(slug).toLowerCase())}`; if (kind !== 'topic') return `/${slug}`; const base = `/topics/${encodeURIComponent(slugFromUrl(slug))}`; return segment ? `${base}/${encodeURIComponent(segment)}` : base; diff --git a/apps/web/src/app/api/following/feed/[format]/route.js b/apps/web/src/app/api/following/feed/[format]/route.js index b06cc0a..07f330e 100644 --- a/apps/web/src/app/api/following/feed/[format]/route.js +++ b/apps/web/src/app/api/following/feed/[format]/route.js @@ -58,7 +58,7 @@ export async function GET(req, { params }) { ); } - const { feeds, topics, items } = await following(client, String(user.id), { + const { feeds, topics, authors, items } = await following(client, String(user.id), { limit: RIVER_LIMIT, }); @@ -85,10 +85,11 @@ export async function GET(req, { params }) { format, { title: 'Following โ€” RSS Amplifier', - description: `Recent posts from the ${count(topics.length, 'topic')} and ${count( - feeds.length, - 'blog', - )} this RSS Amplifier account follows.`, + description: `Recent posts from the ${count(topics.length, 'topic')}, ${count( + authors.length, + 'person', + 'people', + )} and ${count(feeds.length, 'blog')} this RSS Amplifier account follows.`, link: `${origin}/following`, selfUrl: followingFeedUrl(origin, token, format), }, @@ -116,8 +117,8 @@ export async function GET(req, { params }) { * @param {string} noun * @returns {string} */ -function count(n, noun) { - return `${n} ${noun}${n === 1 ? '' : 's'}`; +function count(n, noun, plural = `${noun}s`) { + return `${n} ${n === 1 ? noun : plural}`; } /** diff --git a/apps/web/src/app/api/follows/authors/route.js b/apps/web/src/app/api/follows/authors/route.js new file mode 100644 index 0000000..3990456 --- /dev/null +++ b/apps/web/src/app/api/follows/authors/route.js @@ -0,0 +1,105 @@ +import { accounts, authors } from '@rssamplifier/db'; + +import { db } from '../../../../lib/db.js'; +import { currentUser } from '../../../../lib/auth.js'; + +export const dynamic = 'force-dynamic'; + +/** + * Follow or unfollow a person. + * + * The third sibling of /api/follows and /api/follows/topics, kept apart from + * both for the reason the topics route already gives: they take different + * identifiers, validate differently, and land somewhere different afterwards. + * + * What is different here is the indirection. The table is keyed on the author's + * id, but the request carries their slug, because a slug is the public identity + * and accepting an internal id would mean publishing one. So this resolves the + * slug first, and that lookup is also where a follow on somebody who does not + * exist is refused. + * + * Form-first like every other write on the site: a plain POST answered with a + * 303 back to the page it came from, so following works with JavaScript off. A + * JSON caller gets JSON. + * + * @param {Request} req + */ +export async function POST(req) { + const user = await currentUser(); + const wantsHtml = (req.headers.get('accept') ?? '').includes('text/html'); + + let rawSlug = ''; + let action = 'toggle'; + + try { + if ((req.headers.get('content-type') ?? '').includes('application/json')) { + const body = await req.json(); + rawSlug = String(body?.slug ?? ''); + action = String(body?.action ?? 'toggle'); + } else { + const form = await req.formData(); + rawSlug = String(form.get('slug') ?? ''); + action = String(form.get('action') ?? 'toggle'); + } + } catch { + return json({ error: 'bad-request' }, 400); + } + + // Lowercased the way `authorBySlug` expects and the way the page's own URL is + // written, so a follow made from a link somebody typed in capitals is the + // same row as one made from the page. + const slug = rawSlug.trim().toLowerCase(); + if (!slug) return wantsHtml ? redirect('/authors') : json({ error: 'bad-request' }, 400); + + const page = `/authors/${encodeURIComponent(slug)}`; + + if (!user) { + // Sent to sign in and then back to the person they were reading, rather + // than handed a bare error. + if (wantsHtml) return redirect(`/login?next=${encodeURIComponent(page)}`); + return json({ error: 'sign-in-required' }, 401); + } + + const client = db(); + const person = await authors.authorBySlug(client, slug); + + // Unlike the topic route, this refuses in both directions rather than only on + // follow. A topic slug outlives the topic table by design, so an unfollow has + // to work for a slug that no longer resolves; an author id only exists while + // the author row does, and the cascade has already removed the follow by the + // time the row is gone. There is nothing left to delete and nothing to key it + // by, so a 404 is the honest answer. + if (!person) return wantsHtml ? redirect('/authors') : json({ error: 'not-found' }, 404); + + const userId = String(user.id); + const authorId = String(person.id); + + const following = await accounts.isFollowingAuthor(client, userId, authorId); + const shouldFollow = action === 'follow' || (action === 'toggle' && !following); + + if (shouldFollow) await accounts.followAuthor(client, userId, authorId); + else await accounts.unfollowAuthor(client, userId, authorId); + + if (wantsHtml) return redirect(page); + return json({ ok: true, slug, following: shouldFollow }); +} + +/** + * @param {string} location + * @returns {Response} + */ +function redirect(location) { + return new Response(null, { status: 303, headers: { location, 'cache-control': 'no-store' } }); +} + +/** + * @param {unknown} body + * @param {number} [status] + * @returns {Response} + */ +function json(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, + }); +} diff --git a/apps/web/src/app/authors/[slug]/page.jsx b/apps/web/src/app/authors/[slug]/page.jsx index 030d88c..39c9c97 100644 --- a/apps/web/src/app/authors/[slug]/page.jsx +++ b/apps/web/src/app/authors/[slug]/page.jsx @@ -1,10 +1,12 @@ import { notFound } from 'next/navigation'; -import { authors } from '@rssamplifier/db'; +import { alerts, authors } from '@rssamplifier/db'; import { db, siteUrl } from '../../../lib/db.js'; +import { currentUser } from '../../../lib/auth.js'; import { feedAlternates } from '../../../lib/subscribe.js'; import AdBanner from '../../AdBanner.jsx'; import AuthorLinks from '../../AuthorLinks.jsx'; +import FollowControls from '../../FollowControls.jsx'; import SubscribeLinks from '../../SubscribeLinks.jsx'; import { CATEGORIES } from '../../CategoryIndex.jsx'; import ListFilter from '../../ListFilter.jsx'; @@ -59,6 +61,15 @@ export default async function AuthorPage({ params }) { const feeds = person.feeds ?? []; const links = person.links ?? []; + // Whether this reader already follows them, and whether that follow is + // alerting. One round trip for both, the way the feed and topic pages do it: + // the button and the bell are rendered together and asking twice would be two + // queries for one row. + const user = await currentUser(); + const follow = user + ? await alerts.authorFollowState(db(), String(user.id), String(person.id)) + : { following: false, alerts: false }; + // What they have published lately, read off their own feeds' ids rather than // searched for -- see `postsByAuthor`. A profile that lists the blogs but not // the writing is a card catalogue entry; the point of a page about a person @@ -142,6 +153,29 @@ export default async function AuthorPage({ params }) { + {/* Follow the person, and then decide whether to be told. Above the + subscribe links deliberately: those hand the reader a document to take + somewhere else, and this keeps them here, which is the thing the page + could describe and not offer. + + Only where there is something to follow. A profile with no credited + feeds would produce a follow that can never deliver anything, which is + the same reason the subscribe links below are conditional. */} + {feeds.length > 0 && ( +
+ +
+ )} + {/* Everything they publish, wherever they publish it, as one feed. Only offered when there is something behind it: a subscribe link on a profile with no credited feeds is a link to an empty document. */} diff --git a/apps/web/src/app/following/page.jsx b/apps/web/src/app/following/page.jsx index 1c08f28..2b6229d 100644 --- a/apps/web/src/app/following/page.jsx +++ b/apps/web/src/app/following/page.jsx @@ -10,6 +10,7 @@ import { currentUser } from '../../lib/auth.js'; import { postThumb } from '../../lib/thumbs.js'; import { RIVER_LIMIT, + RIVER_AUTHORS, RIVER_TOPICS, following as loadFollowing, followingFeedUrl, @@ -45,13 +46,13 @@ export default async function FollowingPage({ searchParams }) { const client = db(); const userId = String(user.id); - const [{ feeds, topics, items, topicsUsed }, token] = await Promise.all([ + const [{ feeds, topics, authors, items, topicsUsed, authorsUsed }, token] = await Promise.all([ loadFollowing(client, userId, { limit: RIVER_LIMIT }), accounts.feedToken(client, userId), ]); const origin = siteUrl(); - const nothing = feeds.length === 0 && topics.length === 0; + const nothing = feeds.length === 0 && topics.length === 0 && authors.length === 0; return ( <> @@ -68,14 +69,16 @@ export default async function FollowingPage({ searchParams }) { {nothing ? (

Nothing followed yet. Press Follow on any blog to be - told when they post, or on any topic to be told when anybody posts + told when they post, on any topic to be told when anybody posts about it โ€” ai and ai: podcasts{' '} - are two separate follows, because they are two separate pages. + are two separate follows, because they are two separate pages โ€” or on a{' '} + person, which collects everything they publish wherever they + publish it.

) : (

- {describe(topics.length, 'topic')} and {describe(feeds.length, 'blog')}, merged newest - first. + {describe(topics.length, 'topic')}, {describe(authors.length, 'person', 'people')} and{' '} + {describe(feeds.length, 'blog')}, merged newest first.

)} @@ -84,10 +87,10 @@ export default async function FollowingPage({ searchParams }) {

Topics

{topics.length >= FILTER_FROM && ( - + )} -
    +
      {topics.map((t) => { const label = topicLabel(t); @@ -124,6 +127,43 @@ export default async function FollowingPage({ searchParams }) { )} + {authors.length > 0 && ( + <> +

      People

      + + {authors.length >= FILTER_FROM && ( + + )} + +
        + {authors.map((a) => ( +
      • + {String(a.name)} + {/* Unfollowing lives next to the thing it undoes, told explicitly + which way to go rather than toggling, so a double submit + cannot re-follow what it just removed. */} +
        + + + +
        +
      • + ))} +
      + + {/* Said out loud rather than left as a silent truncation, for the same + reason the topics section says it. */} + {authors.length > authorsUsed && ( +

      + The river below is drawn from the {RIVER_AUTHORS} people you followed most recently. + The rest are still followed โ€” open any of them above for their own page and feed. +

      + )} + + )} + {feeds.length > 0 && ( <>

      Blogs

      @@ -232,16 +272,20 @@ export default async function FollowingPage({ searchParams }) { } /** - * "3 topics", "one blog", "no topics". + * "3 topics", "one blog", "no people". + * + * The plural is a parameter rather than an `s` because one of the three nouns + * here does not take one: "3 persons" is not what anybody says. * * @param {number} n * @param {string} noun + * @param {string} [plural] * @returns {string} */ -function describe(n, noun) { - if (n === 0) return `no ${noun}s`; +function describe(n, noun, plural = `${noun}s`) { + if (n === 0) return `no ${plural}`; if (n === 1) return `one ${noun}`; - return `${n} ${noun}s`; + return `${n} ${plural}`; } /** diff --git a/apps/web/src/lib/following.js b/apps/web/src/lib/following.js index 2f6b108..600c8fa 100644 --- a/apps/web/src/lib/following.js +++ b/apps/web/src/lib/following.js @@ -1,4 +1,4 @@ -import { accounts, q } from '@rssamplifier/db'; +import { accounts, authors as people, q } from '@rssamplifier/db'; import { dedupeItems } from '@rssamplifier/feed'; import { topicGroup } from './topicGroups.js'; @@ -6,14 +6,17 @@ import { topicGroup } from './topicGroups.js'; /** * One reader's river: everything they follow, in one list. * - * Two kinds of follow feed it. A followed **blog** is a publication โ€” tell me + * Three kinds of follow feed it. A followed **blog** is a publication โ€” tell me * when these people post. A followed **topic** is a subject โ€” tell me when * anybody posts about this โ€” and it may be narrowed to one category of that * topic, so /topics/ai and /topics/ai/podcasts are followed separately the way - * they are browsed separately. + * they are browsed separately. A followed **author** is a person, which is none + * of the above: somebody with a blog, a newsletter and a podcast is three + * publications, and following the person is the only way to ask for all three + * and for the fourth they have not started yet. * - * Both end up in the same merged list, because the reader did not ask for two - * lists. What the list keeps per row is where it came from: `via` names the + * All three end up in the same merged list, because the reader did not ask for + * three lists. What the list keeps per row is where it came from: `via` names the * follow that pulled it in, so a post that turned up because of a topic can say * so instead of appearing to come from a blog nobody remembers following. */ @@ -33,6 +36,16 @@ import { topicGroup } from './topicGroups.js'; */ export const RIVER_TOPICS = 12; +/** + * How many followed people the river draws from. + * + * The same cap as topics and for the same reason, one query each. Set to the + * same number rather than a tuned one: an author query reads at most twenty + * feeds by primary key and is cheaper than a topic's, so if twelve topics are + * affordable then twelve people certainly are. + */ +export const RIVER_AUTHORS = 12; + /** * How many posts a following river carries, on the page and in the feed. * @@ -125,25 +138,33 @@ function published(row) { * * @param {import('@libsql/client').Client} client * @param {string} userId - * @param {{ limit?: number, riverTopics?: number }} [opts] + * @param {{ limit?: number, riverTopics?: number, riverAuthors?: number }} [opts] * @returns {Promise<{ * feeds: object[], * topics: object[], + * authors: object[], * items: object[], * topicsUsed: number, + * authorsUsed: number, * }>} */ export async function following(client, userId, opts = {}) { - const { limit = RIVER_LIMIT, riverTopics = RIVER_TOPICS } = opts; + const { + limit = RIVER_LIMIT, + riverTopics = RIVER_TOPICS, + riverAuthors = RIVER_AUTHORS, + } = opts; - const [feeds, topics] = await Promise.all([ + const [feeds, topics, authors] = await Promise.all([ accounts.followedFeeds(client, userId), accounts.followedTopics(client, userId), + accounts.followedAuthors(client, userId), ]); const drawnFrom = topics.slice(0, riverTopics); + const peopleDrawnFrom = authors.slice(0, riverAuthors); - const [feedItems, topicItems] = await Promise.all([ + const [feedItems, topicItems, authorItems] = await Promise.all([ feeds.length ? accounts.followedItems(client, userId, PER_SOURCE) : Promise.resolve([]), Promise.all( drawnFrom.map(async (follow) => { @@ -162,6 +183,19 @@ export async function following(client, userId, opts = {}) { }; }), ), + // One source per followed person. Attributed to the person rather than to + // the publication the row happens to carry, which is the whole reason + // somebody follows an author instead of their blog. + Promise.all( + peopleDrawnFrom.map(async (follow) => ({ + via: { + kind: 'author', + title: String(follow.name || follow.slug), + href: `/authors/${encodeURIComponent(String(follow.slug))}`, + }, + rows: await people.postsByAuthorId(client, String(follow.id), PER_SOURCE), + })), + ), ]); const items = mergeRiver( @@ -171,11 +205,19 @@ export async function following(client, userId, opts = {}) { // carries in feed_slug. { via: { kind: 'feed', title: '', href: '' }, rows: feedItems }, ...topicItems, + ...authorItems, ], limit, ); - return { feeds, topics, items, topicsUsed: drawnFrom.length }; + return { + feeds, + topics, + authors, + items, + topicsUsed: drawnFrom.length, + authorsUsed: peopleDrawnFrom.length, + }; } /** diff --git a/apps/web/test/following.test.js b/apps/web/test/following.test.js index 6f994b3..1751044 100644 --- a/apps/web/test/following.test.js +++ b/apps/web/test/following.test.js @@ -19,6 +19,7 @@ function item(title, publishedAt, extra = {}) { const VIA_TOPIC = { kind: 'topic', title: 'ai', href: '/topics/ai' }; const VIA_FEED = { kind: 'feed', title: '', href: '' }; +const VIA_AUTHOR = { kind: 'author', title: 'Ada Lovelace', href: '/authors/ada-lovelace' }; test('the river is newest first across every source', () => { const merged = mergeRiver([ @@ -111,3 +112,36 @@ test('the feed URL carries the token in the query, where a rewrite cannot lose i const url = followingFeedUrl('https://rssamplifier.com', 'tok+en/1', 'atom'); assert.equal(url, 'https://rssamplifier.com/following.atom?t=tok%2Ben%2F1'); }); + +test('a post pulled in by a followed person is attributed to the person', () => { + // The reason an author is its own source rather than folded into the blogs: + // the row still carries whichever publication it appeared in, and the reader + // needs to be told it arrived because they follow Ada, not because they + // follow a newsletter they may never have heard of. + const merged = mergeRiver([ + { via: VIA_AUTHOR, rows: [item('On someone else\'s newsletter', '2026-06-01T00:00:00Z')] }, + ]); + + assert.equal(merged.length, 1); + assert.equal(merged[0].via.kind, 'author'); + assert.equal(merged[0].via.title, 'Ada Lovelace'); + assert.equal(merged[0].via.href, '/authors/ada-lovelace'); +}); + +test('the same post reached by a person and a topic is one row', () => { + // A reader who follows Ada and also follows the topic she writes about gets + // told once. Whichever telling is newer wins, and its `via` is what survives. + const merged = mergeRiver([ + { + via: VIA_AUTHOR, + rows: [item('one story', '2026-06-01T00:00:00Z', { cluster_key: 'dup' })], + }, + { + via: VIA_TOPIC, + rows: [item('one story again', '2026-05-01T00:00:00Z', { cluster_key: 'dup' })], + }, + ]); + + assert.equal(merged.length, 1); + assert.equal(merged[0].via.kind, 'author'); +}); diff --git a/packages/db/migrations/20260819100115_author_follows.sql b/packages/db/migrations/20260819100115_author_follows.sql new file mode 100644 index 0000000..3751a07 --- /dev/null +++ b/packages/db/migrations/20260819100115_author_follows.sql @@ -0,0 +1,36 @@ +-- Following a person rather than a publication or a subject. +-- +-- `follows` (0003) answers "tell me when this blog posts" and `topic_follows` +-- (0021) answers "tell me when anybody posts about this". Neither answers the +-- question an author page invites: a writer with a blog, a newsletter and a +-- podcast is three rows in `feeds` and one person, and following all three by +-- hand both misses the fourth when they start it and says nothing about who +-- they are. +-- +-- Keyed on `author_id` rather than on the slug, which is the opposite of what +-- `topic_follows` does, and deliberately. A topic slug is the only identity a +-- topic has, because `topics` is a disposable rollup the poller rebuilds. An +-- author is a real row with a stable primary key and a unique `identity_key` +-- that the extractor merges on, and nothing in the codebase ever deletes one. +-- So the foreign key is honest here, and it buys the cascade: an author that +-- does go away takes its follows with it instead of leaving rows pointing at +-- a page that 404s. + +create table if not exists author_follows ( + user_id text not null references users (id) on delete cascade, + author_id text not null references authors (id) on delete cascade, + + -- Off by default, matching both older follow tables. Following is "collect + -- this for me"; being interrupted is a second decision, made with the bell. + alerts integer not null default 0, + + created_at text not null, + + primary key (user_id, author_id) +); + +-- The following page's query: what one reader follows, most recent first. +create index if not exists author_follows_user_idx on author_follows (user_id, created_at desc); + +-- The other direction, for "how many people follow this author". +create index if not exists author_follows_author_idx on author_follows (author_id); diff --git a/packages/db/src/accounts.js b/packages/db/src/accounts.js index 52e06ce..d0ed394 100644 --- a/packages/db/src/accounts.js +++ b/packages/db/src/accounts.js @@ -528,6 +528,98 @@ export async function followedTopics(db, userId, limit = 200) { return rows; } +/* ------------------------------------------------------------------ * + * Following a person + * ------------------------------------------------------------------ */ + +/** + * Follow an author. + * + * Takes the author's id rather than their slug, unlike the topic pair above, + * because that is what `author_follows` is keyed on and an author has a real + * one. The route resolves the slug it was given first, which is also where a + * request for somebody who does not exist is refused. + * + * @param {Client} db + * @param {string} userId + * @param {string} authorId + */ +export async function followAuthor(db, userId, authorId) { + await db.execute({ + sql: `insert into author_follows (user_id, author_id, created_at) values (?, ?, ?) + on conflict do nothing`, + args: [userId, authorId, nowIso()], + }); +} + +/** + * @param {Client} db + * @param {string} userId + * @param {string} authorId + */ +export async function unfollowAuthor(db, userId, authorId) { + await db.execute({ + sql: 'delete from author_follows where user_id = ? and author_id = ?', + args: [userId, authorId], + }); +} + +/** + * @param {Client} db + * @param {string} userId + * @param {string} authorId + * @returns {Promise} + */ +export async function isFollowingAuthor(db, userId, authorId) { + const { rows } = await db.execute({ + sql: 'select 1 as n from author_follows where user_id = ? and author_id = ? limit 1', + args: [userId, authorId], + }); + return rows.length > 0; +} + +/** + * The people one reader follows, most recently followed first. + * + * The name and slug are joined back rather than copied into the follow, for + * the reason `followedTopics` gives: the authors table is where a person's + * name lives, and a second copy here would be a second copy to keep in step + * with an extractor that improves its answer over time. + * + * @param {Client} db + * @param {string} userId + * @param {number} [limit] + * @returns {Promise} + */ +export async function followedAuthors(db, userId, limit = 200) { + const { rows } = await db.execute({ + sql: `select a.id, a.slug, a.name, a.avatar_url, af.created_at as followed_at, + (select count(*) from feed_authors fa where fa.author_id = a.id) as feed_count + from author_follows af + join authors a on a.id = af.author_id + where af.user_id = ? + order by af.created_at desc + limit ?`, + args: [userId, limit], + }); + return rows; +} + +/** + * How many readers follow one author. + * + * @param {Client} db + * @param {string} authorId + * @returns {Promise} + */ +export async function authorFollowerCount(db, authorId) { + const { rows } = await db.execute({ + sql: 'select count(*) as n from author_follows where author_id = ?', + args: [authorId], + }); + return Number(rows[0]?.n ?? 0); +} + /** * How many readers follow a topic, or one category of it. * diff --git a/packages/db/src/alerts.js b/packages/db/src/alerts.js index 0de07b6..f68cd2c 100644 --- a/packages/db/src/alerts.js +++ b/packages/db/src/alerts.js @@ -141,6 +141,40 @@ export async function topicFollowState(db, userId, slug, segment = '') { return { following: true, alerts: Number(rows[0]?.alerts ?? 0) === 1 }; } +/** + * Turn alerts on or off for a followed author. + * + * @param {Client} db + * @param {string} userId + * @param {string} authorId + * @param {boolean} on + * @returns {Promise} + */ +export async function setAuthorAlerts(db, userId, authorId, on) { + const res = await db.execute({ + sql: 'update author_follows set alerts = ? where user_id = ? and author_id = ?', + args: [on ? 1 : 0, userId, authorId], + }); + return Number(res.rowsAffected ?? 0) > 0; +} + +/** + * The same, for a person. + * + * @param {Client} db + * @param {string} userId + * @param {string} authorId + * @returns {Promise<{ following: boolean, alerts: boolean }>} + */ +export async function authorFollowState(db, userId, authorId) { + const { rows } = await db.execute({ + sql: 'select alerts from author_follows where user_id = ? and author_id = ? limit 1', + args: [userId, authorId], + }); + if (rows.length === 0) return { following: false, alerts: false }; + return { following: true, alerts: Number(rows[0]?.alerts ?? 0) === 1 }; +} + /** * Everything one account has switched alerts on for, both kinds together. * @@ -152,7 +186,7 @@ export async function topicFollowState(db, userId, slug, segment = '') { * @returns {Promise<{ feeds: object[], topics: object[] }>} */ export async function alertingFollows(db, userId) { - const [feeds, topics] = await Promise.all([ + const [feeds, topics, people] = await Promise.all([ db.execute({ sql: `select f.slug, f.title from follows fo join feeds f on f.id = fo.feed_id @@ -168,9 +202,16 @@ export async function alertingFollows(db, userId) { order by tf.created_at desc`, args: [userId], }), + db.execute({ + sql: `select a.slug, a.name + from author_follows af join authors a on a.id = af.author_id + where af.user_id = ? and af.alerts = 1 + order by af.created_at desc`, + args: [userId], + }), ]); - return { feeds: feeds.rows, topics: topics.rows }; + return { feeds: feeds.rows, topics: topics.rows, authors: people.rows }; } /* ---------------------------------------------------------------- channels */ @@ -441,7 +482,9 @@ export async function usersWithAlerts(db, limit = 50) { and (exists (select 1 from follows fo where fo.user_id = u.id and fo.alerts = 1) or exists (select 1 from topic_follows tf - where tf.user_id = u.id and tf.alerts = 1)) + where tf.user_id = u.id and tf.alerts = 1) + or exists (select 1 from author_follows af + where af.user_id = u.id and af.alerts = 1)) order by s.updated_at is not null, s.updated_at limit ?`, args: [limit], @@ -474,7 +517,9 @@ export async function alertingAccountCount(db) { and (exists (select 1 from follows fo where fo.user_id = u.id and fo.alerts = 1) or exists (select 1 from topic_follows tf - where tf.user_id = u.id and tf.alerts = 1))`, + where tf.user_id = u.id and tf.alerts = 1) + or exists (select 1 from author_follows af + where af.user_id = u.id and af.alerts = 1))`, ); return Number(rows[0]?.n ?? 0); } @@ -643,6 +688,75 @@ export async function newItemsForTopic(db, slug, cursor, opts = {}) { return rows; } +/** + * How many of an author's feeds an alert draws from. + * + * A person credited on more than this many feeds is either extremely prolific + * or a mis-merge, and both are better bounded than trusted: the query runs once + * per alerting author per account per tick. + */ +export const ALERT_AUTHOR_FEEDS = 20; + +/** + * The people an account has alerting. + * + * @param {Client} db + * @param {string} userId + * @param {number} [limit] + * @returns {Promise} + */ +export async function alertedAuthors(db, userId, limit = 50) { + const { rows } = await db.execute({ + sql: `select a.id, a.slug, a.name + from author_follows af join authors a on a.id = af.author_id + where af.user_id = ? and af.alerts = 1 + order by af.created_at desc + limit ?`, + args: [userId, limit], + }); + return rows; +} + +/** + * New posts by one alerting author, oldest first. + * + * An author is defined by `feed_authors`, so this reads their feeds rather than + * a column on the item. Filtered on `created_at` for the same reason every + * other alert query is: the watermark is a point in ingest time, and + * `published_at` is whatever the publisher claimed. + * + * Dead feeds are excluded on the same grounds the topic query excludes them. A + * feed that stopped resolving still carries its old items, and an author whose + * blog moved should not have the move announced as new writing. + * + * @param {Client} db + * @param {string} authorId + * @param {string} cursor + * @param {{ limit?: number, feedCap?: number }} [opts] + * @returns {Promise} + */ +export async function newItemsForAuthor(db, authorId, cursor, opts = {}) { + const { limit = 50, feedCap = ALERT_AUTHOR_FEEDS } = opts; + + const { rows } = await db.execute({ + sql: `with picked as ( + select fa.feed_id from feed_authors fa + join feeds f on f.id = fa.feed_id and f.status <> 'dead' + where fa.author_id = ? + limit ? + ) + select ${ALERT_COLS} + from feed_items i + join feeds f on f.id = i.feed_id + where i.feed_id in (select feed_id from picked) + and i.created_at > ? + order by i.created_at + limit ?`, + args: [authorId, feedCap, cursor, limit], + }); + return rows; +} + /* --------------------------------------------------------------- sent-log */ /** diff --git a/packages/db/src/authors.js b/packages/db/src/authors.js index b33aa81..2413215 100644 --- a/packages/db/src/authors.js +++ b/packages/db/src/authors.js @@ -853,6 +853,51 @@ export async function postsByAuthor(db, feedIds, limit = 12) { return rows; } +/** + * How many of one author's feeds a river reads. + * + * The same bound `ALERT_AUTHOR_FEEDS` puts on the alert query, for the same + * reason: somebody credited on more feeds than this is a mis-merge rather than + * a polymath, and either way the page should not pay for it. + */ +export const RIVER_AUTHOR_FEEDS = 20; + +/** + * What one person has published lately, by their id. + * + * The sibling of `postsByAuthor`, which takes feed ids because its caller โ€” the + * author page โ€” has already loaded them. The river has not: it holds a list of + * follows and would otherwise have to fetch every author in full simply to + * learn which feeds are theirs, which is one round trip per followed person + * before a single post has been read. + * + * @param {Client} db + * @param {string} authorId + * @param {number} [limit] + * @returns {Promise} + */ +export async function postsByAuthorId(db, authorId, limit = 60) { + const { rows } = await db.execute({ + sql: `with picked as ( + select fa.feed_id from feed_authors fa + join feeds f on f.id = fa.feed_id and f.status <> 'dead' + where fa.author_id = ? + limit ? + ) + select i.guid, i.title, i.url, i.summary, i.published_at, i.created_at, + i.image_url, i.audio_url, i.audio_type, i.audio_seconds, i.cluster_key, + f.slug as feed_slug, f.title as feed_title, f.category, f.feed_url + from feed_items i + join feeds f on f.id = i.feed_id + where i.feed_id in (select feed_id from picked) + order by i.published_at desc nulls last, i.created_at desc + limit ?`, + args: [authorId, RIVER_AUTHOR_FEEDS, limit], + }); + + return rows; +} + /* ------------------------------------------------------------------ * * Bought searches * ------------------------------------------------------------------ */ diff --git a/packages/db/test/author-follows.test.js b/packages/db/test/author-follows.test.js new file mode 100644 index 0000000..ef730db --- /dev/null +++ b/packages/db/test/author-follows.test.js @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict'; +import { test, before, after } from 'node:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { connect, migrate, q, accounts, alerts, authors } from '../index.js'; + +/** + * Following a person, and being told when they publish. + * + * The third kind of follow, after the blog (0003) and the topic (0021). What + * makes it worth its own table rather than a flag on either is the thing these + * tests are mostly about: a person is not a publication, so a follow on one has + * to keep working when they publish somewhere new. + */ + +let dir; +let db; + +before(async () => { + dir = await mkdtemp(join(tmpdir(), 'rssamp-author-follows-')); + db = connect({ url: `file:${join(dir, 'test.db')}` }); + await migrate(db); +}); + +after(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +/** + * @param {string} slug + * @param {Array<{ guid: string, title: string, publishedAt?: string }>} [items] + */ +async function feedWith(slug, items = []) { + const feed = await q.insertFeed(db, { + slug, + feed_url: `https://${slug}.example/feed.xml`, + site_url: `https://${slug}.example/`, + title: `${slug} blog`, + kind: 'blog', + status: 'active', + }); + if (items.length) await q.upsertItems(db, String(feed.id), items); + return feed; +} + +/** + * @param {string} slug + * @param {string} name + */ +async function personCalled(slug, name) { + const { id } = await authors.upsertAuthor(db, { + identityKey: `mailto:${slug}@example.com`, + slug, + name, + normName: name.toLowerCase(), + confidence: 0.9, + }); + return String(id); +} + +test('following a person is idempotent, and unfollowing removes it', async () => { + const user = await accounts.findOrCreateUser(db, 'follows@example.com'); + const ada = await personCalled('ada-lovelace', 'Ada Lovelace'); + + await accounts.followAuthor(db, user.id, ada); + // Twice over: a double submit must not become a constraint error or a second + // row, the same guarantee the topic follows make. + await accounts.followAuthor(db, user.id, ada); + + assert.equal(await accounts.isFollowingAuthor(db, user.id, ada), true); + assert.equal((await accounts.followedAuthors(db, user.id)).length, 1); + assert.equal(await accounts.authorFollowerCount(db, ada), 1); + + await accounts.unfollowAuthor(db, user.id, ada); + assert.equal(await accounts.isFollowingAuthor(db, user.id, ada), false); + assert.equal((await accounts.followedAuthors(db, user.id)).length, 0); +}); + +test('a followed person carries their name and how much they publish', async () => { + const user = await accounts.findOrCreateUser(db, 'named@example.com'); + const grace = await personCalled('grace-hopper', 'Grace Hopper'); + + const blog = await feedWith('grace-blog'); + const pod = await feedWith('grace-pod'); + await authors.linkFeedAuthor(db, String(blog.id), grace, { role: 'owner', confidence: 0.9 }); + await authors.linkFeedAuthor(db, String(pod.id), grace, { role: 'owner', confidence: 0.9 }); + + await accounts.followAuthor(db, user.id, grace); + const [row] = await accounts.followedAuthors(db, user.id); + + // Joined back rather than copied into the follow: the extractor improves its + // answer over time and a copy here would be a copy to keep in step. + assert.equal(String(row.name), 'Grace Hopper'); + assert.equal(String(row.slug), 'grace-hopper'); + assert.equal(Number(row.feed_count), 2); +}); + +test('alerts are off until asked for, and only on a follow that exists', async () => { + const user = await accounts.findOrCreateUser(db, 'bell@example.com'); + const ada = await personCalled('ada-two', 'Ada Two'); + + // Nothing to flag: the bell must refuse rather than quietly create a follow, + // which is the whole distinction between the button and the bell. + assert.equal(await alerts.setAuthorAlerts(db, user.id, ada, true), false); + assert.deepEqual(await alerts.authorFollowState(db, user.id, ada), { + following: false, + alerts: false, + }); + + await accounts.followAuthor(db, user.id, ada); + assert.deepEqual(await alerts.authorFollowState(db, user.id, ada), { + following: true, + alerts: false, + }); + + assert.equal(await alerts.setAuthorAlerts(db, user.id, ada, true), true); + assert.deepEqual(await alerts.authorFollowState(db, user.id, ada), { + following: true, + alerts: true, + }); + + const listed = await alerts.alertingFollows(db, user.id); + assert.deepEqual( + listed.authors.map((a) => String(a.name)), + ['Ada Two'], + ); +}); + +test('an alerting person yields their new posts from every feed they write', async () => { + const user = await accounts.findOrCreateUser(db, 'items@example.com'); + const alan = await personCalled('alan-turing', 'Alan Turing'); + + const blog = await feedWith('alan-blog', [{ guid: 't1', title: 'On the blog' }]); + const pod = await feedWith('alan-pod', [{ guid: 't2', title: 'On the podcast' }]); + await authors.linkFeedAuthor(db, String(blog.id), alan, { role: 'owner', confidence: 0.9 }); + await authors.linkFeedAuthor(db, String(pod.id), alan, { role: 'owner', confidence: 0.9 }); + + await accounts.followAuthor(db, user.id, alan); + await alerts.setAuthorAlerts(db, user.id, alan, true); + + const alerting = await alerts.alertedAuthors(db, user.id); + assert.equal(alerting.length, 1); + assert.equal(String(alerting[0].id), alan); + + // From the beginning of time, so everything seeded above is "new". + const rows = await alerts.newItemsForAuthor(db, alan, '1970-01-01T00:00:00.000Z'); + + assert.deepEqual( + rows.map((r) => String(r.title)).sort(), + ['On the blog', 'On the podcast'], + 'both publications, which is the point of following the person', + ); + + // Nothing is new once the watermark has passed it. A far-future cursor stands + // in for "already told about all of this". + const none = await alerts.newItemsForAuthor(db, alan, '2999-01-01T00:00:00.000Z'); + assert.equal(none.length, 0); +}); + +test('a dead feed contributes nothing, so a move is not announced as new writing', async () => { + const user = await accounts.findOrCreateUser(db, 'dead@example.com'); + const kay = await personCalled('alan-kay', 'Alan Kay'); + + const gone = await feedWith('kay-old', [{ guid: 'k1', title: 'From the old blog' }]); + await db.execute({ sql: `update feeds set status = 'dead' where id = ?`, args: [String(gone.id)] }); + await authors.linkFeedAuthor(db, String(gone.id), kay, { role: 'owner', confidence: 0.9 }); + + await accounts.followAuthor(db, user.id, kay); + await alerts.setAuthorAlerts(db, user.id, kay, true); + + const rows = await alerts.newItemsForAuthor(db, kay, '1970-01-01T00:00:00.000Z'); + assert.equal(rows.length, 0); +}); + +test('deleting the author takes the follow with it', async () => { + const user = await accounts.findOrCreateUser(db, 'cascade@example.com'); + const ghost = await personCalled('ghost-writer', 'Ghost Writer'); + + await accounts.followAuthor(db, user.id, ghost); + assert.equal(await accounts.isFollowingAuthor(db, user.id, ghost), true); + + // The reason the table keys on author_id with a foreign key rather than on a + // slug: nothing is left pointing at a page that would 404. + await db.execute({ sql: 'delete from authors where id = ?', args: [ghost] }); + + assert.equal(await accounts.isFollowingAuthor(db, user.id, ghost), false); + assert.equal((await accounts.followedAuthors(db, user.id)).length, 0); +}); diff --git a/packages/notify/src/deliver.js b/packages/notify/src/deliver.js index 986b4e6..3a0e43e 100644 --- a/packages/notify/src/deliver.js +++ b/packages/notify/src/deliver.js @@ -179,12 +179,15 @@ async function deliverForUser(db, user, opts) { * @returns {Promise>} */ async function readSources(db, userId, cursor, opts) { - const alerting = await alerts.alertedTopics(db, userId, opts.topics); + const [alertingTopics, alertingAuthors] = await Promise.all([ + alerts.alertedTopics(db, userId, opts.topics), + alerts.alertedAuthors(db, userId, opts.topics), + ]); - const [feedRows, topicSources] = await Promise.all([ + const [feedRows, topicSources, authorSources] = await Promise.all([ alerts.newItemsFromAlertedFeeds(db, userId, cursor, opts.perSource), Promise.all( - alerting.map(async (follow) => { + alertingTopics.map(async (follow) => { const slug = String(follow.slug); const segment = String(follow.segment ?? ''); const rows = await alerts.newItemsForTopic(db, slug, cursor, { @@ -195,6 +198,19 @@ async function readSources(db, userId, cursor, opts) { return { via: topicVia(follow), rows, capped: rows.length >= opts.perSource }; }), ), + // A person is its own source rather than being folded into the blogs, + // because the attribution differs: a post that arrives because you follow + // Ada is "Ada published this", wherever she published it, and the feed the + // row carries is the publication rather than the reason it was sent. + Promise.all( + alertingAuthors.map(async (follow) => { + const rows = await alerts.newItemsForAuthor(db, String(follow.id), cursor, { + limit: opts.perSource, + }); + + return { via: authorVia(follow), rows, capped: rows.length >= opts.perSource }; + }), + ), ]); return [ @@ -202,6 +218,7 @@ async function readSources(db, userId, cursor, opts) { // blog is attributed to the blog, which every row already carries. { via: { kind: 'feed', title: '', href: '' }, rows: feedRows, capped: feedRows.length >= opts.perSource }, ...topicSources, + ...authorSources, ]; } @@ -229,6 +246,20 @@ export function topicVia(follow) { return { kind: 'topic', title: `${keyword}: ${segment}`, href: `${path}/${segment}` }; } +/** + * What one author follow is called in an alert, and where it points. + * + * @param {{ slug: unknown, name?: unknown }} follow + * @returns {{ kind: string, title: string, href: string }} + */ +export function authorVia(follow) { + const slug = String(follow.slug ?? ''); + // The slug is a serviceable fallback for a row whose name went missing, the + // same way the topic label falls back to its own slug. + const name = String(follow.name || slug); + return { kind: 'author', title: name, href: `/authors/${encodeURIComponent(slug)}` }; +} + /** * Choose what to send, and where the watermark lands. * diff --git a/packages/notify/src/render.js b/packages/notify/src/render.js index a9bd094..349ad4f 100644 --- a/packages/notify/src/render.js +++ b/packages/notify/src/render.js @@ -122,11 +122,20 @@ export function renderEmail(items, opts = {}) { /** * How a post says which follow brought it, when that is not simply its blog. * + * A followed blog needs no suffix: the post is already attributed to it, and + * "via" the thing it plainly came from reads as noise. A topic and a person + * both do need one, because in either case the reason it arrived is not the + * publication printed beside it. Following someone who writes in four places is + * the whole point of an author follow, so the name is the part that explains + * the alert. + * * @param {ReturnType} item * @returns {string} */ function viaSuffix(item) { - return item.via.kind === 'topic' ? ` ยท via ${item.via.title}` : ''; + const { kind, title } = item.via; + if (!title) return ''; + return kind === 'topic' || kind === 'author' ? ` ยท via ${title}` : ''; } /** diff --git a/packages/notify/test/deliver.test.js b/packages/notify/test/deliver.test.js index 7bdf5b8..b9e95f2 100644 --- a/packages/notify/test/deliver.test.js +++ b/packages/notify/test/deliver.test.js @@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { connect, migrate, newId, accounts, alerts, q } from '@rssamplifier/db'; +import { connect, migrate, newId, accounts, alerts, authors, q } from '@rssamplifier/db'; import { deliverAlerts } from '../src/deliver.js'; @@ -127,6 +127,26 @@ async function topic(feedId, slug) { }); } +/** + * Credit a feed to a person, which is what makes an author follow deliver. + * + * @param {string} feedId + * @param {string} slug + * @param {string} name + * @returns {Promise} + */ +async function credit(feedId, slug, name) { + const { id } = await authors.upsertAuthor(db, { + identityKey: `mailto:${slug}@example.com`, + slug, + name, + normName: name.toLowerCase(), + confidence: 0.9, + }); + await authors.linkFeedAuthor(db, feedId, String(id), { role: 'owner', confidence: 0.9 }); + return String(id); +} + const run = (opts = {}) => deliverAlerts(db, { transport: recorder(), origin: 'https://x.test', ...opts }); test('the first pass sends nothing and starts the clock', async () => { @@ -212,6 +232,46 @@ test('alerting on a topic catches a blog that is not followed', async () => { assert.match(sent.email[0].text, /via gardening/); }); +test('alerting on a person catches every publication they write for', async () => { + const userId = await reader(); + // Two publications, neither of them followed. The follow is on the human. + const blog = await feed({ slug: 'her-blog', title: 'Her Blog' }); + const guest = await feed({ slug: 'someone-elses', title: "Someone Else's Newsletter" }); + const ada = await credit(blog, 'ada-lovelace', 'Ada Lovelace'); + await authors.linkFeedAuthor(db, guest, ada, { role: 'author', confidence: 0.9 }); + + await accounts.followAuthor(db, userId, ada); + await alerts.setAuthorAlerts(db, userId, ada, true); + + await run(); + await post(blog, { guid: 'at-home', title: 'Written at home', createdAt: future(1) }); + await post(guest, { guid: 'away', title: 'Written as a guest', createdAt: future(2) }); + + const result = await run(); + + // The whole point of following a person: the guest post arrives even though + // nothing about that newsletter was ever followed. + assert.equal(result.items, 2); + assert.match(sent.email[0].text, /Written at home/); + assert.match(sent.email[0].text, /Written as a guest/); + // Attributed to her rather than to a publication the reader does not know. + assert.match(sent.email[0].text, /via Ada Lovelace/); +}); + +test('an author follow with the bell off stays quiet', async () => { + const userId = await reader(); + const blog = await feed({ slug: 'quiet-blog' }); + const grace = await credit(blog, 'grace-hopper', 'Grace Hopper'); + + // Followed, deliberately not alerting. Collecting is not being interrupted. + await accounts.followAuthor(db, userId, grace); + + await run(); + await post(blog, { guid: 'unheard', title: 'Not worth waking you', createdAt: future(1) }); + + assert.equal((await run()).items, 0); +}); + test('a topic follow narrowed to a category ignores the others', async () => { const userId = await reader(); const blog = await feed({ slug: 'ai-blog', kind: 'blog' });