Skip to main content

max / makenotwork

seed: Phase 4 — blog posts + follow graph Add the editorial/social layer. seed::blog publishes two posts per project (10 total) via create_blog_post, rendering body_html the same way the live handler does; they drive the project blog page and per-project RSS. seed::social seeds a follow graph — every creator follows every other creator and every project but their own — so follower counts render on profile and project pages. Reviews/comments are dropped: no server-side surface exists (only git issue_comments; blog discussion is the remote MT service). Fix the reset: blog_posts.author_id -> users is not ON DELETE CASCADE, so the single DELETE FROM users broke once posts existed. reset_example_data now deletes example creators' projects first (cascading items, blog posts, versions, tiers, tags), then the users. DB test seeds_blog_posts_and_follows: 10 published posts, 20+20 follows, 4 followers spot-checked, idempotent. Suite now 6 tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-11 23:00 UTC
Commit: c4ca57869661ec09112382c258a396344fb98a63
Parent: 9d4d7b5
5 files changed, +227 insertions, -8 deletions
@@ -1 +1,40 @@
1 - //! Seed blog posts per project (drives blog + RSS + landing). Filled in Phase 4.
1 + //! Seed blog posts on each project (Phase 4).
2 + //!
3 + //! Drives the project blog page (`/p/{slug}/blog`) and per-project RSS
4 + //! (`/p/{slug}/blog/feed.xml`). Posts are published (`published_at = NOW()`); the
5 + //! owning project is already public and the creator non-sandbox, so they render to
6 + //! anonymous fans. `body_html` is pre-rendered the same way the live blog handler
7 + //! does (`render_creator_markdown`).
8 +
9 + use super::projects::SeededProject;
10 + use super::SeedError;
11 + use crate::db;
12 + use crate::formatting::slugify;
13 + use crate::markdown::render_creator_markdown;
14 +
15 + /// Publish every roster blog post under its project.
16 + pub async fn seed_blog(pool: &sqlx::PgPool, projects: &[SeededProject]) -> Result<(), SeedError> {
17 + for project in projects {
18 + for post in project.spec.blog {
19 + let slug = slugify(post.title);
20 + // Bodies are plain prose with no embedded media, so the CDN base used
21 + // for media-path rewriting is irrelevant here.
22 + let body_html = render_creator_markdown(post.body, project.user_id, "");
23 + db::blog_posts::create_blog_post(
24 + pool,
25 + project.project.id,
26 + project.user_id,
27 + post.title,
28 + &slug,
29 + post.body,
30 + &body_html,
31 + true, // publish -> published_at = NOW()
32 + false, // web_only
33 + false, // show_on_landing (inert off the changelog project)
34 + )
35 + .await?;
36 + tracing::info!(title = post.title, slug = %slug, "example seed: published blog post");
37 + }
38 + }
39 + Ok(())
40 + }
@@ -44,6 +44,13 @@ pub struct TierSpec {
44 44 pub price_cents: i32,
45 45 }
46 46
47 + /// A blog post on a project (Phase 4). Drives the blog page + per-project RSS.
48 + pub struct BlogSpec {
49 + pub title: &'static str,
50 + /// Markdown body (plain prose, no embedded media).
51 + pub body: &'static str,
52 + }
53 +
47 54 /// One item within a project. Media (real files) attaches in Phase 3; Phase 2
48 55 /// seeds the row, its pricing, and its tags. `body` (Text items) is set inline.
49 56 pub struct ItemSpec {
@@ -74,6 +81,8 @@ pub struct ProjectSpec {
74 81 pub tiers: &'static [TierSpec],
75 82 /// Items to seed under this project.
76 83 pub items: &'static [ItemSpec],
84 + /// Blog posts to publish on this project (Phase 4).
85 + pub blog: &'static [BlogSpec],
77 86 }
78 87
79 88 /// One fabricated creator plus the project they own.
@@ -119,6 +128,16 @@ const ROSTER: &[CreatorSpec] = &[
119 128 Every release is free to share; pay what it's worth if it moves you.",
120 129 project: ProjectSpec {
121 130 slug: "restored-reels-vol-1",
131 + blog: &[
132 + BlogSpec {
133 + title: "Vol. 1 is out",
134 + body: "The first volume of Restored Reels is up. Three recordings, cleaned from public-domain sources and remastered for easy listening.\n\nEverything here is free to stream and share. If a restoration moves you, name your price — it keeps the next volume coming.",
135 + },
136 + BlogSpec {
137 + title: "How we restore a reel",
138 + body: "Restoration is mostly patience: de-noise gently, level the dynamics, and leave the character of the original intact.\n\nWe never \"improve\" a performance. The goal is to let a public-domain recording sound like itself, only clearer.",
139 + },
140 + ],
122 141 title: "Restored Reels, Vol. 1",
123 142 description: "A first volume of public-domain recordings, cleaned up \
124 143 and remastered for easy listening. Free to stream and \
@@ -166,6 +185,16 @@ const ROSTER: &[CreatorSpec] = &[
166 185 lock-in.",
167 186 project: ProjectSpec {
168 187 slug: "deskriver-suite",
188 + blog: &[
189 + BlogSpec {
190 + title: "One download, buy it once",
191 + body: "The Deskriver Suite is a single download. Pay once and it is yours — no account to run it, no subscription, no cloud lock-in.\n\nUpdates are free for the life of the release. When there is something new, you will see it in the app.",
192 + },
193 + BlogSpec {
194 + title: "Why the tools stay offline",
195 + body: "Every tool in the suite runs fully offline and stores your data locally. There is nothing to sign in to and nothing phoning home.\n\nThat is a deliberate choice: the tools should keep working whether or not we do.",
196 + },
197 + ],
169 198 title: "Deskriver Suite",
170 199 description: "A bundle of small, source-available desktop utilities. \
171 200 Runs offline with no account required, stores \
@@ -221,6 +250,16 @@ const ROSTER: &[CreatorSpec] = &[
221 250 domain. Download, print, remix — no permission needed.",
222 251 project: ProjectSpec {
223 252 slug: "cc0-field-library",
253 + blog: &[
254 + BlogSpec {
255 + title: "Opening the field library",
256 + body: "The CC0 Field Library is open. Landscape and still-life studies, released to the public domain, added to over time.\n\nDownload the full-resolution files and use them however you like. No attribution required, though it is always welcome.",
257 + },
258 + BlogSpec {
259 + title: "Why these are CC0",
260 + body: "Public-domain photography should be genuinely usable — in a book, a zine, a website, a wall.\n\nSo everything here is CC0: no rights reserved, no permission to ask for, no strings.",
261 + },
262 + ],
224 263 title: "CC0 Field Library",
225 264 description: "A growing library of landscape and still-life \
226 265 photography released to the public domain. Download the \
@@ -247,6 +286,16 @@ const ROSTER: &[CreatorSpec] = &[
247 286 readable editions. New volumes for subscribers each month.",
248 287 project: ProjectSpec {
249 288 slug: "the-marginalia-reader",
289 + blog: &[
290 + BlogSpec {
291 + title: "The first monthly volume",
292 + body: "The first monthly volume of The Marginalia Reader is ready for subscribers. Public-domain literature, re-typeset into clean editions, delivered as EPUB and PDF.\n\nA new volume goes out each month. Read slowly.",
293 + },
294 + BlogSpec {
295 + title: "On typesetting the commons",
296 + body: "A good edition disappears: you notice the text, not the type. That takes careful measure — line length, leading, generous margins.\n\nEvery volume in the reader is set by hand from a public-domain source, then proofed twice before it ships.",
297 + },
298 + ],
250 299 title: "The Marginalia Reader",
251 300 description: "Public-domain literature, re-typeset into clean and \
252 301 readable editions. Subscribers get a new volume every \
@@ -302,6 +351,16 @@ const ROSTER: &[CreatorSpec] = &[
302 351 commons, not to us.",
303 352 project: ProjectSpec {
304 353 slug: "commons-sampler",
354 + blog: &[
355 + BlogSpec {
356 + title: "What the account funds",
357 + body: "Commonshare is a benefit account. Everything here is free, and any support routed through it passes straight to community projects.\n\nThe value goes to the commons, not to us. That is the whole point of the account.",
358 + },
359 + BlogSpec {
360 + title: "All of this is free",
361 + body: "The Commons Sampler collects freely licensed work — music and art — in one place to download.\n\nNothing here costs anything. If you want to give back, the benefit account passes it along.",
362 + },
363 + ],
305 364 title: "Commons Sampler",
306 365 description: "A free sampler of work shared through the Commonshare \
307 366 benefit account. Everything here is free to download; \
@@ -154,7 +154,11 @@ pub async fn run(pool: &PgPool, opts: &SeedOptions, media: &SeedMedia) -> Result
154 154 // Phase 3: attach placeholder media and flip items visible. Skipped (items
155 155 // stay hidden) when storage is unconfigured.
156 156 media::seed_media(pool, media, &projects).await?;
157 - tracing::warn!("example seed: blog/social pending (Phase 4)");
157 +
158 + // Phase 4: blog posts (blog page + RSS) and a follow graph (renders counts).
159 + blog::seed_blog(pool, &projects).await?;
160 + social::seed_social(pool, &projects).await?;
161 + tracing::warn!("example seed: forum (Phase 5) deferred; refresh-flow swap is Phase 6");
158 162
159 163 Ok(())
160 164 }
@@ -201,12 +205,26 @@ async fn count_real_users(pool: &PgPool) -> Result<i64, sqlx::Error> {
201 205 .await
202 206 }
203 207
204 - /// Delete every example account, cascading to its projects, items, and related
205 - /// rows. Returns the number of accounts removed. Safe only after the guards have
206 - /// confirmed no real data is present.
208 + /// Delete every example account and its content. Returns the number of accounts
209 + /// removed. Safe only after the guards have confirmed no real data is present.
210 + ///
211 + /// Projects are deleted first, on purpose: that cascades their items, blog posts,
212 + /// versions, tiers, and tags (all `ON DELETE CASCADE` on `project_id`). It clears
213 + /// `blog_posts.author_id` in particular, whose FK to `users` is *not*
214 + /// `ON DELETE CASCADE` and would otherwise block the subsequent `DELETE FROM users`
215 + /// (the user-scoped `follows` rows do cascade on `follower_id`).
207 216 async fn reset_example_data(pool: &PgPool) -> Result<u64, sqlx::Error> {
217 + let like = format!("%@{EXAMPLE_EMAIL_DOMAIN}");
218 + sqlx::query(
219 + "DELETE FROM projects WHERE user_id IN \
220 + (SELECT id FROM users WHERE lower(email) LIKE $1)",
221 + )
222 + .bind(&like)
223 + .execute(pool)
224 + .await?;
225 +
208 226 let res = sqlx::query("DELETE FROM users WHERE lower(email) LIKE $1")
209 - .bind(format!("%@{EXAMPLE_EMAIL_DOMAIN}"))
227 + .bind(&like)
210 228 .execute(pool)
211 229 .await?;
212 230 Ok(res.rows_affected())
@@ -1,2 +1,35 @@
1 - //! Seed follows/fans (and reviews/comments where the surface exists) so counts
2 - //! render. Filled in Phase 4.
1 + //! Seed follows between the example accounts (Phase 4) so follower counts render
2 + //! on profile and project pages.
3 + //!
4 + //! `db::follows::follow` is idempotent (`ON CONFLICT DO NOTHING`) and follow rows
5 + //! cascade when the follower is deleted, so this is re-runnable and cleaned up by
6 + //! the seed reset. There is no server-side review/comment surface, so engagement
7 + //! is limited to follows.
8 +
9 + use super::projects::SeededProject;
10 + use super::SeedError;
11 + use crate::db::{self, FollowTargetType};
12 +
13 + /// Seed a small follow graph: every creator follows every other creator (User)
14 + /// and every project except their own (Project). Each account ends with four
15 + /// followers, enough for non-trivial counts on every profile and project page.
16 + pub async fn seed_social(pool: &sqlx::PgPool, projects: &[SeededProject]) -> Result<(), SeedError> {
17 + for a in projects {
18 + for b in projects {
19 + if a.user_id == b.user_id {
20 + continue;
21 + }
22 + db::follows::follow(pool, a.user_id, FollowTargetType::User, *b.user_id.as_uuid())
23 + .await?;
24 + db::follows::follow(
25 + pool,
26 + a.user_id,
27 + FollowTargetType::Project,
28 + *b.project.id.as_uuid(),
29 + )
30 + .await?;
31 + }
32 + }
33 + tracing::info!("example seed: seeded follows between example accounts");
34 + Ok(())
35 + }
@@ -248,6 +248,76 @@ async fn refuses_on_prod_host() {
248 248 assert_eq!(count_example_creators(&db.pool).await, 0);
249 249 }
250 250
251 + #[tokio::test]
252 + async fn seeds_blog_posts_and_follows() {
253 + let db = TestDb::new().await;
254 + seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
255 + .await
256 + .expect("seed");
257 +
258 + // Two published posts per project (10), all published.
259 + let posts: i64 = sqlx::query_scalar(
260 + "SELECT COUNT(*) FROM blog_posts bp JOIN projects p ON p.id = bp.project_id \
261 + JOIN users u ON u.id = p.user_id \
262 + WHERE lower(split_part(u.email,'@',2))='example.test' AND bp.published_at IS NOT NULL",
263 + )
264 + .fetch_one(&db.pool)
265 + .await
266 + .expect("published posts");
267 + assert_eq!(posts, 10, "expected 2 published blog posts per project");
268 +
269 + // Follow graph: 20 user-follows + 20 project-follows among example accounts.
270 + let user_follows: i64 = sqlx::query_scalar(
271 + "SELECT COUNT(*) FROM follows f JOIN users fu ON fu.id = f.follower_id \
272 + WHERE f.target_type = 'user' AND lower(split_part(fu.email,'@',2))='example.test'",
273 + )
274 + .fetch_one(&db.pool)
275 + .await
276 + .expect("user follows");
277 + assert_eq!(user_follows, 20);
278 + let project_follows: i64 = sqlx::query_scalar(
279 + "SELECT COUNT(*) FROM follows f JOIN users fu ON fu.id = f.follower_id \
280 + WHERE f.target_type = 'project' AND lower(split_part(fu.email,'@',2))='example.test'",
281 + )
282 + .fetch_one(&db.pool)
283 + .await
284 + .expect("project follows");
285 + assert_eq!(project_follows, 20);
286 +
287 + // Spot-check a rendered count: each creator has 4 followers.
288 + let followers_of_openreels: i64 = sqlx::query_scalar(
289 + "SELECT COUNT(*) FROM follows f \
290 + WHERE f.target_type = 'user' \
291 + AND f.target_id = (SELECT id FROM users WHERE username = 'openreels')",
292 + )
293 + .fetch_one(&db.pool)
294 + .await
295 + .expect("openreels followers");
296 + assert_eq!(followers_of_openreels, 4);
297 +
298 + // Idempotent: re-run keeps 10 posts + 40 follows, not doubled.
299 + seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
300 + .await
301 + .expect("re-seed");
302 + let posts2: i64 = sqlx::query_scalar(
303 + "SELECT COUNT(*) FROM blog_posts bp JOIN projects p ON p.id = bp.project_id \
304 + JOIN users u ON u.id = p.user_id \
305 + WHERE lower(split_part(u.email,'@',2))='example.test'",
306 + )
307 + .fetch_one(&db.pool)
308 + .await
309 + .expect("posts after re-run");
310 + assert_eq!(posts2, 10, "re-run should not duplicate blog posts");
311 + let follows2: i64 = sqlx::query_scalar(
312 + "SELECT COUNT(*) FROM follows f JOIN users fu ON fu.id = f.follower_id \
313 + WHERE lower(split_part(fu.email,'@',2))='example.test'",
314 + )
315 + .fetch_one(&db.pool)
316 + .await
317 + .expect("follows after re-run");
318 + assert_eq!(follows2, 40, "re-run should not duplicate follows");
319 + }
320 +
251 321 /// Guard-passing options bundled with an in-memory storage backend, so the media
252 322 /// phase runs and flips items visible.
253 323 fn media_ctx() -> SeedMedia {