max / makenotwork
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 { |