//! 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>, } /// 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. 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], } /// 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."; /// 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", 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"], pricing: ProjectPricing::Pwyw { min: 100 }, 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, }, 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, }, 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, }, ], }, }, 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", 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"], pricing: ProjectPricing::BuyOnce(900), 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, }, 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, }, 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, }, 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, }, ], }, }, 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", 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, }], }, }, 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", 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), }, 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, }, ], }, }, 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", 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, }], }, }, ]; /// 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) }