From 58364137b9ea52ddc76e7c2c26b92a3eed883252 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 19 Aug 2026 10:11:09 +0000 Subject: [PATCH] Follow a person, not just their publications /authors/ could describe somebody and hand you a feed of everything they write, and then had nothing for the reader who wanted to be told. Following was only ever a blog or a topic, which are the two things a person is not: somebody with a blog, a newsletter and a podcast is three rows in `feeds`, and following all three by hand still misses the fourth when they start it. So: a third kind of follow, with the same bell beside it. `author_follows` keys on `author_id` rather than on the slug, which is the opposite of what `topic_follows` does and deliberate. A topic slug is a topic's only identity because `topics` is a rollup the poller rebuilds; an author is a real row with a stable id and a unique identity_key the extractor merges on, so the foreign key is honest and buys the cascade. A test pins that: deleting the author removes the follow rather than leaving one pointing at a 404. It behaves like the other two everywhere they do. The page renders FollowControls, so the button flips in place and the bell appears beside it once following, and both are plain forms that work with JavaScript off. /following grows a People section with its own unfollow, and their posts merge into the river attributed to the person rather than to whichever publication carried them, which is the entire reason for following a human. The personal feed counts them, /account/alerts lists them, and an alert says "via Ada Lovelace". The delivery test found a real bug on the way in. `usersWithAlerts` asks whether an account has any alerting follow, and its two EXISTS clauses were blogs and topics: a reader whose only alerts were on people would have been skipped by the sender entirely, silently and forever. `alertingAccountCount` had the same predicate and the same gap, which would then have under-reported the sender's own health on /crawlstats. Both now include the third table. Verified end to end in a browser against a seeded author with two feeds: signed out the button invites sign-in and shows no bell; signed in it flips to Following with no navigation; the bell turns on; and the person then appears on /following with both her posts in the river, and on /account/alerts. 1,093 tests pass, build clean, both on Node 22. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/app/FollowControls.jsx | 4 +- apps/web/src/app/account/alerts/page.jsx | 19 +- apps/web/src/app/api/alerts/route.js | 29 ++- .../app/api/following/feed/[format]/route.js | 15 +- apps/web/src/app/api/follows/authors/route.js | 105 ++++++++++ apps/web/src/app/authors/[slug]/page.jsx | 36 +++- apps/web/src/app/following/page.jsx | 68 +++++-- apps/web/src/lib/following.js | 62 +++++- apps/web/test/following.test.js | 34 ++++ .../20260819100115_author_follows.sql | 36 ++++ packages/db/src/accounts.js | 92 +++++++++ packages/db/src/alerts.js | 122 ++++++++++- packages/db/src/authors.js | 45 +++++ packages/db/test/author-follows.test.js | 190 ++++++++++++++++++ packages/notify/src/deliver.js | 37 +++- packages/notify/src/render.js | 11 +- packages/notify/test/deliver.test.js | 62 +++++- 17 files changed, 920 insertions(+), 47 deletions(-) create mode 100644 apps/web/src/app/api/follows/authors/route.js create mode 100644 packages/db/migrations/20260819100115_author_follows.sql create mode 100644 packages/db/test/author-follows.test.js 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' });