//! Seed the fabricated example creators (`@example.test`) and hold the roster //! that drives the rest of the seed. //! //! The roster is the single source of truth for the content phases: each //! [`CreatorSpec`] carries the creator's public identity plus the one project //! they own (built by [`super::projects`]) and that project's items, pricing, and //! subscription tiers (built by [`super::items`]). Naming follows the locked //! 2026-07-11 convention, real apps never appear under their real names; //! public-domain / CC0 media accounts get thematic handles, purely-generated copy //! gets invented names. use uuid::Uuid; use super::{EXAMPLE_EMAIL_DOMAIN, SeedError}; use crate::auth; use crate::db::{self, DbUser, Email, ItemType, Username}; /// Per-item pricing. The item's `PricingKind` is *derived* from these fields /// (`pricing.rs::for_item`): price 0 → Free, price > 0 → BuyOnce, `pwyw_enabled` /// → Pwyw. Subscription is a project-level concern ([`ProjectPricing`]), not an /// item price. pub enum ItemPricing { /// `price_cents = 0`. Free, /// `price_cents = cents` (> 0). BuyOnce(i32), /// Pay-what-you-want with a minimum of `min` cents. Pwyw { min: i32 }, } /// A project's headline pricing model (`projects.pricing_model`). pub enum ProjectPricing { Free, BuyOnce(i32), Pwyw { min: i32, }, /// Access via subscription tiers ([`ProjectSpec::tiers`]). Subscription, } /// A subscription tier on a project (`subscription_tiers`). pub struct TierSpec { pub name: &'static str, pub description: &'static str, pub price_cents: i32, } /// A blog post on a project (Phase 4). Drives the blog page + per-project RSS. pub struct BlogSpec { pub title: &'static str, /// Markdown body (plain prose, no embedded media). pub body: &'static str, } /// One item within a project. Media (real files) attaches in Phase 3; Phase 2 /// seeds the row, its pricing, and its tags. `body` (Text items) is set inline. pub struct ItemSpec { pub title: &'static str, pub description: &'static str, pub item_type: ItemType, pub pricing: ItemPricing, /// Pre-seeded tag slugs to attach (resolved via `get_tag_by_slug`; unknown /// slugs are skipped). The first present slug becomes the primary tag. pub tags: &'static [&'static str], /// Markdown body for Text items; `None` for everything else. pub body: Option<&'static str>, /// Manifest id of the item's primary file (`media-manifest.toml`). `None` /// for item types that serve a generated blob rather than a real file, and /// for Image items, whose cover *is* the work. An id that is declared but /// uncurated falls back to the generated placeholder. pub media: Option<&'static str>, /// Manifest id of the item's cover art. `None` keeps the grey placeholder. pub cover: Option<&'static str>, } /// The single project a seeded creator owns, plus its content. pub struct ProjectSpec { /// URL slug (auto-suffixed on the rare collision by `create_project`). pub slug: &'static str, /// Public project title. pub title: &'static str, /// Project description (re-skinned from `content_seed.md`, no real-app names). pub description: &'static str, /// Enabled features; `create_project` derives `project_type` from these. pub features: &'static [&'static str], /// Headline pricing model for the project. /// /// Keep this `Free` unless the point of the project *is* the paywall. /// A non-free project renders `ProjectPaywallTemplate` to anyone without /// access, and that template lists no items at all /// (`Project::from_db(db_project, 0)`). testnot is a public no-login demo, /// so every visitor is "without access" forever: a paid project there is a /// storefront nobody can see inside. Spreading the four `PricingKind`s /// across projects hid 9 of the 11 seeded items this way until 2026-08-05. /// /// Price the *items* instead. Item pricing is visible from the storefront /// (the card links to `/purchase/{id}`), so BuyOnce and Pwyw demonstrate /// themselves without closing the door. `Subscription` is the exception: it /// is project-level by construction, so marginalia keeps it and is the one /// paywall on the box. pub pricing: ProjectPricing, /// Subscription tiers (only non-empty for a `Subscription` project). pub tiers: &'static [TierSpec], /// Items to seed under this project. pub items: &'static [ItemSpec], /// Blog posts to publish on this project (Phase 4). pub blog: &'static [BlogSpec], /// Manifest id of the project's cover art (`media-manifest.toml`). `None` /// keeps the grey placeholder. pub cover: Option<&'static str>, } /// One fabricated creator plus the project they own. struct CreatorSpec { /// Login handle and local-part of `{handle}@example.test`. handle: &'static str, /// Display name shown on the profile. display_name: &'static str, /// Short bio, matter-of-fact, no pomp, no real-app names. bio: &'static str, /// The project this creator owns. project: ProjectSpec, } /// A creator after insertion, paired with the project spec still to be seeded. pub struct SeededCreator { /// The durable, publicly-visible creator row (`is_sandbox = FALSE`). pub user: DbUser, /// The project [`super::projects::seed_projects`] will create for this user. pub project: &'static ProjectSpec, } /// A short original essay body for the Text item (generic, public-domain-flavored /// prose written for the seed, not copied from any real work). const SLOW_READING_BODY: &str = "\ There is a kind of reading that resists hurry. It asks you to sit with a sentence \ until it gives up its second meaning, and then its third. We have built our tools for the opposite habit, to skim, to extract, to move on. \ This press is a small argument against that. The editions here are set with wide \ margins on purpose: room for a pencil, room for a second thought. Read slowly. Read the same page twice. The commons is patient, and so are its books."; /// Body for "Marginal Notes: On Editions". Original prose, same as above. const ON_EDITIONS_BODY: &str = "\ An edition is an argument about how a text should be met. Where the line breaks, \ how wide the margin runs, whether a note sits at the foot of the page or is exiled \ to the back: each is a claim about what the reader is for. The claims we make here are modest. Long measure tires the eye, so the measure is \ short. Notes belong where the sentence that needs them is, so they stay on the page. \ Nothing is set in a face that wants to be admired. A good edition disappears. You should finish it remembering the book."; /// Body for "The Reader, No. 1". const READER_ONE_BODY: &str = "\ The first number collects three short pieces from the commons, chosen for how they \ sit next to each other rather than for any theme. The oldest is a traveller's account, written by someone with no gift for landscape \ and an unusual ear for how people speak. The second is a letter that was never sent. \ The third is a fragment, and it stops where the manuscript does. Fragments are worth setting properly. A text that breaks off mid-thought is not a \ failure of the text."; /// Body for "The Reader, No. 2". const READER_TWO_BODY: &str = "\ Two essays this month, both on work, both by people who did not think of themselves \ as writers. The first was set down by a printer late in a long career, and reads like someone \ explaining a trade to a nephew who has not asked. The second is angrier and shorter, \ and was published anonymously, which was the ordinary caution of the time. Neither has been edited for taste. The spelling is regularised and nothing else."; /// The five content creators. Their projects span all four `PricingKind`s and, /// across their items, every `ItemType`. See the sprint doc /// `_private/docs/mnw/testnot-example-seed.md`. const ROSTER: &[CreatorSpec] = &[ CreatorSpec { handle: "openreels", display_name: "Open Reels", bio: "A small label restoring and reissuing public-domain recordings. \ Every release is free to share; pay what it's worth if it moves you.", project: ProjectSpec { slug: "restored-reels-vol-1", cover: Some("openreels-project-cover"), blog: &[ BlogSpec { title: "Vol. 1 is out", 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.", }, BlogSpec { title: "How we restore a reel", 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.", }, ], title: "Restored Reels, Vol. 1", description: "A first volume of public-domain recordings, cleaned up \ and remastered for easy listening. Free to stream and \ share; name your price if you'd like to support the \ restoration work.", features: &["audio"], // Free at the project level so the storefront is browsable; the Pwyw // demonstration lives on the items below. See the pricing note on // `ProjectSpec::pricing`. pricing: ProjectPricing::Free, tiers: &[], items: &[ ItemSpec { title: "Restoration No. 1 (Full Mix)", description: "The complete restored recording, remastered from \ a public-domain source. Name your price.", item_type: ItemType::Audio, pricing: ItemPricing::Pwyw { min: 100 }, tags: &["audio", "audio.format.music"], body: None, media: Some("restoration-1-audio"), cover: Some("restoration-1-cover"), }, ItemSpec { title: "Stem Pack: Strings", description: "Isolated string stems from the restoration, for \ sampling and study. WAV, ready for your DAW.", item_type: ItemType::Sample, pricing: ItemPricing::Pwyw { min: 200 }, tags: &["audio.format.samples", "audio.technique.sampling"], body: None, media: Some("stem-pack-strings-audio"), cover: Some("stem-pack-strings-cover"), }, ItemSpec { title: "Session Take (Video)", description: "A short archival video of the session, restored \ and captioned.", item_type: ItemType::Video, pricing: ItemPricing::Pwyw { min: 100 }, tags: &["video", "video.genre.music-video"], body: None, media: Some("session-take-video"), cover: Some("session-take-cover"), }, ItemSpec { title: "Restoration No. 2 (Full Mix)", description: "The second restoration in the volume, from a \ later reel. Name your price.", item_type: ItemType::Audio, pricing: ItemPricing::Pwyw { min: 100 }, tags: &["audio", "audio.format.music"], body: None, media: Some("restoration-2-audio"), cover: Some("restoration-2-cover"), }, ItemSpec { title: "Stem Pack: Brass", description: "Isolated brass stems from the second restoration. \ WAV, ready for your DAW.", item_type: ItemType::Sample, pricing: ItemPricing::Pwyw { min: 200 }, tags: &["audio.format.samples", "audio.technique.sampling"], body: None, media: None, cover: Some("stem-pack-brass-cover"), }, ], }, }, CreatorSpec { handle: "deskriver", display_name: "Deskriver Tools", bio: "Source-available desktop tools that stay out of your way. No \ accounts required to run them, no engagement tricks, no cloud \ lock-in.", project: ProjectSpec { slug: "deskriver-suite", cover: Some("deskriver-project-cover"), blog: &[ BlogSpec { title: "One download, buy it once", 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.", }, BlogSpec { title: "Why the tools stay offline", 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.", }, ], title: "Deskriver Suite", description: "A bundle of small, source-available desktop utilities. \ Runs offline with no account required, stores \ everything locally, and stays out of your way. One \ download, buy it once.", features: &["downloads", "license_keys"], // Free at the project level so the storefront is browsable; the four // items below are BuyOnce at $3-$12 and carry the paid demonstration. // See the pricing note on `ProjectSpec::pricing`. pricing: ProjectPricing::Free, tiers: &[], items: &[ ItemSpec { title: "Deskriver Focus (Plugin)", description: "A focus-timer plugin for the suite. Signed \ builds, offline, no telemetry.", item_type: ItemType::Plugin, pricing: ItemPricing::BuyOnce(1200), tags: &["software.format.plugin", "software.format.vst3"], body: None, media: None, cover: Some("deskriver-focus-cover"), }, ItemSpec { title: "Minimal Preset Pack", description: "A set of restrained presets for the suite, \ sensible defaults, nothing flashy.", item_type: ItemType::Preset, pricing: ItemPricing::BuyOnce(500), tags: &["software", "software.platform.macos"], body: None, media: None, cover: Some("deskriver-presets-cover"), }, ItemSpec { title: "Weekly-Review Template", description: "A ready-to-use weekly-review layout. Import it \ and adapt it to your week.", item_type: ItemType::Template, pricing: ItemPricing::BuyOnce(300), tags: &["writing.topic.productivity", "software"], body: None, media: None, cover: Some("deskriver-template-cover"), }, ItemSpec { title: "Deskriver Utility (Download)", description: "The core command-line utility. Signed and \ notarized for macOS; runs fully offline.", item_type: ItemType::Digital, pricing: ItemPricing::BuyOnce(900), tags: &[ "software.format.cli", "software.platform.macos", "software.format.desktop", ], body: None, media: None, cover: Some("deskriver-utility-cover"), }, ItemSpec { title: "Deskriver Notes (Plugin)", description: "A plain-text notes panel for the suite. Local \ files, no database, no sync.", item_type: ItemType::Plugin, pricing: ItemPricing::BuyOnce(800), tags: &["software.format.plugin", "software.platform.macos"], body: None, media: None, cover: Some("deskriver-notes-cover"), }, ], }, }, CreatorSpec { handle: "stillfield", display_name: "Stillfield", bio: "Quiet landscape and still-life photography, released to the public \ domain. Download, print, remix, no permission needed.", project: ProjectSpec { slug: "cc0-field-library", cover: Some("stillfield-project-cover"), blog: &[ BlogSpec { title: "Opening the field library", 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.", }, BlogSpec { title: "Why these are CC0", 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.", }, ], title: "CC0 Field Library", description: "A growing library of landscape and still-life \ photography released to the public domain. Download the \ full-resolution files and use them however you like, no \ attribution required.", features: &["downloads"], pricing: ProjectPricing::Free, tiers: &[], items: &[ ItemSpec { title: "Field Study 01 (Print)", description: "A high-resolution landscape study, released CC0. \ Free to download, print, and remix.", item_type: ItemType::Image, pricing: ItemPricing::Free, tags: &["visual.medium.photography", "visual"], body: None, media: None, cover: Some("field-study-01-cover"), }, ItemSpec { title: "Field Study 02 (Print)", description: "A second landscape study from the same series. \ Full resolution, CC0.", item_type: ItemType::Image, pricing: ItemPricing::Free, tags: &["visual.medium.photography", "visual"], body: None, media: None, cover: Some("field-study-02-cover"), }, ItemSpec { title: "Field Study 03 (Print)", description: "Late light over open ground. Full resolution, \ CC0, no attribution required.", item_type: ItemType::Image, pricing: ItemPricing::Free, tags: &["visual.medium.photography", "visual"], body: None, media: None, cover: Some("field-study-03-cover"), }, ItemSpec { title: "Still Life 01 (Print)", description: "A quiet interior arrangement, shot in daylight. \ Full resolution, CC0.", item_type: ItemType::Image, pricing: ItemPricing::Free, tags: &["visual.medium.photography", "visual"], body: None, media: None, cover: Some("still-life-01-cover"), }, ItemSpec { title: "Still Life 02 (Print)", description: "The same table, a different hour. Full \ resolution, CC0.", item_type: ItemType::Image, pricing: ItemPricing::Free, tags: &["visual.medium.photography", "visual"], body: None, media: None, cover: Some("still-life-02-cover"), }, ], }, }, CreatorSpec { handle: "marginalia", display_name: "Marginalia Press", bio: "A one-person press typesetting public-domain literature into clean, \ readable editions. New volumes for subscribers each month.", project: ProjectSpec { slug: "the-marginalia-reader", cover: Some("marginalia-project-cover"), blog: &[ BlogSpec { title: "The first monthly volume", 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.", }, BlogSpec { title: "On typesetting the commons", 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.", }, ], title: "The Marginalia Reader", description: "Public-domain literature, re-typeset into clean and \ readable editions. Subscribers get a new volume every \ month, delivered as EPUB and PDF.", features: &["text", "blog", "subscriptions"], pricing: ProjectPricing::Subscription, tiers: &[ TierSpec { name: "Reader", description: "The monthly edition, EPUB and PDF.", price_cents: 300, }, TierSpec { name: "Patron", description: "The monthly edition plus the working notes and \ early proofs.", price_cents: 600, }, TierSpec { name: "Benefactor", description: "Everything, plus a printed copy of the annual \ collection.", price_cents: 1200, }, ], items: &[ ItemSpec { title: "On Slow Reading", description: "The opening essay of the reader, an argument \ for reading twice.", item_type: ItemType::Text, pricing: ItemPricing::Free, tags: &["writing.format.essay", "writing.topic.creativity"], body: Some(SLOW_READING_BODY), media: None, cover: Some("on-slow-reading-cover"), }, ItemSpec { title: "Typesetting the Commons", description: "A short course on preparing public-domain texts \ as clean digital editions.", item_type: ItemType::Course, pricing: ItemPricing::Free, tags: &["education.format.course", "education.topic.writing"], body: None, media: None, cover: Some("typesetting-commons-cover"), }, ItemSpec { title: "The Reader, No. 1", description: "The first monthly number: three short pieces from \ the commons, newly set.", item_type: ItemType::Text, pricing: ItemPricing::Free, tags: &["writing.format.essay", "writing"], body: Some(READER_ONE_BODY), media: None, cover: Some("reader-one-cover"), }, ItemSpec { title: "The Reader, No. 2", description: "Two essays on work, by people who did not think of \ themselves as writers.", item_type: ItemType::Text, pricing: ItemPricing::Free, tags: &["writing.format.essay", "writing"], body: Some(READER_TWO_BODY), media: None, cover: Some("reader-two-cover"), }, ItemSpec { title: "Marginal Notes: On Editions", description: "What a margin, a measure, and a footnote each \ claim about the reader.", item_type: ItemType::Text, pricing: ItemPricing::Free, tags: &["writing.format.essay", "writing.topic.creativity"], body: Some(ON_EDITIONS_BODY), media: None, cover: Some("on-editions-cover"), }, ], }, }, CreatorSpec { handle: "commonshare", display_name: "Commonshare", bio: "A benefit account: everything here is free, and any support routed \ through it passes straight to community projects. Value to the \ commons, not to us.", project: ProjectSpec { slug: "commons-sampler", cover: Some("commonshare-project-cover"), blog: &[ BlogSpec { title: "What the account funds", 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.", }, BlogSpec { title: "All of this is free", 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.", }, ], title: "Commons Sampler", description: "A free sampler of work shared through the Commonshare \ benefit account. Everything here is free to download; \ anything routed through the account passes straight to \ community projects.", features: &["downloads"], pricing: ProjectPricing::Free, tiers: &[], items: &[ ItemSpec { title: "Community Bundle Vol. 1", description: "A free bundle collecting highlights shared through \ the account, music and art, all freely licensed.", item_type: ItemType::Bundle, pricing: ItemPricing::Free, tags: &["audio", "visual"], body: None, media: None, cover: Some("community-bundle-cover"), }, ItemSpec { title: "Community Bundle Vol. 2", description: "The second collection. Same terms: free to \ download, free to pass on.", item_type: ItemType::Bundle, pricing: ItemPricing::Free, tags: &["audio", "visual"], body: None, media: None, cover: Some("community-bundle-2-cover"), }, ItemSpec { title: "Sampler: Field Recordings", description: "Room tone, weather, and open ground. Freely \ licensed, for anything you like.", item_type: ItemType::Sample, pricing: ItemPricing::Free, tags: &["audio.format.samples", "audio"], body: None, media: None, cover: Some("field-recordings-cover"), }, ItemSpec { title: "Sampler: Public-Domain Loops", description: "Short loops cut from public-domain recordings, \ tempo-labelled and ready to drop in.", item_type: ItemType::Sample, pricing: ItemPricing::Free, tags: &["audio.format.samples", "audio.technique.sampling"], body: None, media: None, cover: Some("pd-loops-cover"), }, ItemSpec { title: "Commons Reader (Download)", description: "A collected PDF of the writing shared through the \ account this year.", item_type: ItemType::Digital, pricing: ItemPricing::Free, tags: &["writing", "writing.format.essay"], body: None, media: None, cover: Some("commons-reader-cover"), }, ], }, }, ]; /// Create every roster creator: a durable `@example.test` account with a profile. /// /// Returns the created accounts paired with their project specs so /// [`super::projects::seed_projects`] can build one project per creator. Called /// only from [`super::run`], after its prod-safety guards and the example-data /// reset. Idempotency comes from that reset (prior example accounts are wiped /// first), so this always inserts fresh rows. pub async fn seed_creators(pool: &sqlx::PgPool) -> Result, SeedError> { let mut seeded = Vec::with_capacity(ROSTER.len()); for spec in ROSTER { // Never-login account: hash a random string (the sandbox pattern). The // stored hash is a valid Argon2id hash no one holds the input to. let password_hash = auth::hash_password_async(format!("example-seed_{}", Uuid::new_v4())).await?; let username = Username::from_trusted(spec.handle.to_string()); let email = Email::from_trusted(format!("{}@{EXAMPLE_EMAIL_DOMAIN}", spec.handle)); let user = db::users::create_example_creator(pool, &username, &email, &password_hash).await?; let user = db::users::update_user_profile(pool, user.id, Some(spec.display_name), Some(spec.bio)) .await?; tracing::info!( handle = spec.handle, user_id = %user.id, "example seed: created creator" ); seeded.push(SeededCreator { user, project: &spec.project, }); } Ok(seeded) } #[cfg(test)] mod tests { use super::*; use std::collections::HashSet; /// Every manifest id the roster names. fn referenced_ids() -> Vec<&'static str> { let mut ids = Vec::new(); for creator in ROSTER { ids.extend(creator.project.cover); for item in creator.project.items { ids.extend(item.media); ids.extend(item.cover); } } ids } /// The roster and the manifest have to agree, in both directions. A typo in /// either one is otherwise silent: the slot keeps its grey placeholder and /// the box looks merely uncurated rather than broken. #[test] fn roster_and_manifest_agree() { let manifest = super::super::manifest::Manifest::load().expect("the manifest must be loadable"); let declared: HashSet<&str> = manifest.ids().collect(); let referenced: HashSet<&str> = referenced_ids().into_iter().collect(); let undeclared: Vec<&str> = referenced.difference(&declared).copied().collect(); assert!( undeclared.is_empty(), "roster references ids the manifest does not declare: {undeclared:?}" ); let unused: Vec<&str> = declared.difference(&referenced).copied().collect(); assert!( unused.is_empty(), "manifest declares ids nothing references; curating them would upload \ files no page shows: {unused:?}" ); } /// Two slots may share an asset, but not an id by accident: a duplicate here /// usually means a copy-paste, not a deliberate reuse. #[test] fn each_id_is_referenced_once() { let ids = referenced_ids(); let mut seen = HashSet::with_capacity(ids.len()); for id in ids { assert!(seen.insert(id), "asset id {id:?} is referenced twice"); } } /// Image items serve their cover as the work, so a `media` id on one would /// upload a file nothing links to. #[test] fn image_items_carry_no_separate_media() { for creator in ROSTER { for item in creator.project.items { if matches!(item.item_type, ItemType::Image | ItemType::Text) { assert!( item.media.is_none(), "{:?} item {:?} declares media, which nothing uploads", item.item_type, item.title ); } } } } }