Skip to main content

max / makenotwork

19.6 KB · 426 lines History Blame Raw
1 //! Seed the fabricated example creators (`@example.test`) and hold the roster
2 //! that drives the rest of the seed.
3 //!
4 //! The roster is the single source of truth for the content phases: each
5 //! [`CreatorSpec`] carries the creator's public identity plus the one project
6 //! they own (built by [`super::projects`]) and that project's items, pricing, and
7 //! subscription tiers (built by [`super::items`]). Naming follows the locked
8 //! 2026-07-11 convention, real apps never appear under their real names;
9 //! public-domain / CC0 media accounts get thematic handles, purely-generated copy
10 //! gets invented names.
11
12 use uuid::Uuid;
13
14 use super::{EXAMPLE_EMAIL_DOMAIN, SeedError};
15 use crate::auth;
16 use crate::db::{self, DbUser, Email, ItemType, Username};
17
18 /// Per-item pricing. The item's `PricingKind` is *derived* from these fields
19 /// (`pricing.rs::for_item`): price 0 → Free, price > 0 → BuyOnce, `pwyw_enabled`
20 /// → Pwyw. Subscription is a project-level concern ([`ProjectPricing`]), not an
21 /// item price.
22 pub enum ItemPricing {
23 /// `price_cents = 0`.
24 Free,
25 /// `price_cents = cents` (> 0).
26 BuyOnce(i32),
27 /// Pay-what-you-want with a minimum of `min` cents.
28 Pwyw { min: i32 },
29 }
30
31 /// A project's headline pricing model (`projects.pricing_model`).
32 pub enum ProjectPricing {
33 Free,
34 BuyOnce(i32),
35 Pwyw {
36 min: i32,
37 },
38 /// Access via subscription tiers ([`ProjectSpec::tiers`]).
39 Subscription,
40 }
41
42 /// A subscription tier on a project (`subscription_tiers`).
43 pub struct TierSpec {
44 pub name: &'static str,
45 pub description: &'static str,
46 pub price_cents: i32,
47 }
48
49 /// A blog post on a project (Phase 4). Drives the blog page + per-project RSS.
50 pub struct BlogSpec {
51 pub title: &'static str,
52 /// Markdown body (plain prose, no embedded media).
53 pub body: &'static str,
54 }
55
56 /// One item within a project. Media (real files) attaches in Phase 3; Phase 2
57 /// seeds the row, its pricing, and its tags. `body` (Text items) is set inline.
58 pub struct ItemSpec {
59 pub title: &'static str,
60 pub description: &'static str,
61 pub item_type: ItemType,
62 pub pricing: ItemPricing,
63 /// Pre-seeded tag slugs to attach (resolved via `get_tag_by_slug`; unknown
64 /// slugs are skipped). The first present slug becomes the primary tag.
65 pub tags: &'static [&'static str],
66 /// Markdown body for Text items; `None` for everything else.
67 pub body: Option<&'static str>,
68 }
69
70 /// The single project a seeded creator owns, plus its content.
71 pub struct ProjectSpec {
72 /// URL slug (auto-suffixed on the rare collision by `create_project`).
73 pub slug: &'static str,
74 /// Public project title.
75 pub title: &'static str,
76 /// Project description (re-skinned from `content_seed.md`, no real-app names).
77 pub description: &'static str,
78 /// Enabled features; `create_project` derives `project_type` from these.
79 pub features: &'static [&'static str],
80 /// Headline pricing model for the project.
81 pub pricing: ProjectPricing,
82 /// Subscription tiers (only non-empty for a `Subscription` project).
83 pub tiers: &'static [TierSpec],
84 /// Items to seed under this project.
85 pub items: &'static [ItemSpec],
86 /// Blog posts to publish on this project (Phase 4).
87 pub blog: &'static [BlogSpec],
88 }
89
90 /// One fabricated creator plus the project they own.
91 struct CreatorSpec {
92 /// Login handle and local-part of `{handle}@example.test`.
93 handle: &'static str,
94 /// Display name shown on the profile.
95 display_name: &'static str,
96 /// Short bio, matter-of-fact, no pomp, no real-app names.
97 bio: &'static str,
98 /// The project this creator owns.
99 project: ProjectSpec,
100 }
101
102 /// A creator after insertion, paired with the project spec still to be seeded.
103 pub struct SeededCreator {
104 /// The durable, publicly-visible creator row (`is_sandbox = FALSE`).
105 pub user: DbUser,
106 /// The project [`super::projects::seed_projects`] will create for this user.
107 pub project: &'static ProjectSpec,
108 }
109
110 /// A short original essay body for the Text item (generic, public-domain-flavored
111 /// prose written for the seed, not copied from any real work).
112 const SLOW_READING_BODY: &str = "\
113 There is a kind of reading that resists hurry. It asks you to sit with a sentence \
114 until it gives up its second meaning, and then its third.
115
116 We have built our tools for the opposite habit — to skim, to extract, to move on. \
117 This press is a small argument against that. The editions here are set with wide \
118 margins on purpose: room for a pencil, room for a second thought.
119
120 Read slowly. Read the same page twice. The commons is patient, and so are its books.";
121
122 /// The five content creators. Their projects span all four `PricingKind`s and,
123 /// across their items, every `ItemType`. See the sprint doc
124 /// `_private/docs/mnw/testnot-example-seed.md`.
125 const ROSTER: &[CreatorSpec] = &[
126 CreatorSpec {
127 handle: "openreels",
128 display_name: "Open Reels",
129 bio: "A small label restoring and reissuing public-domain recordings. \
130 Every release is free to share; pay what it's worth if it moves you.",
131 project: ProjectSpec {
132 slug: "restored-reels-vol-1",
133 blog: &[
134 BlogSpec {
135 title: "Vol. 1 is out",
136 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.",
137 },
138 BlogSpec {
139 title: "How we restore a reel",
140 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.",
141 },
142 ],
143 title: "Restored Reels, Vol. 1",
144 description: "A first volume of public-domain recordings, cleaned up \
145 and remastered for easy listening. Free to stream and \
146 share; name your price if you'd like to support the \
147 restoration work.",
148 features: &["audio"],
149 pricing: ProjectPricing::Pwyw { min: 100 },
150 tiers: &[],
151 items: &[
152 ItemSpec {
153 title: "Restoration No. 1 (Full Mix)",
154 description: "The complete restored recording, remastered from \
155 a public-domain source. Name your price.",
156 item_type: ItemType::Audio,
157 pricing: ItemPricing::Pwyw { min: 100 },
158 tags: &["audio", "audio.format.music"],
159 body: None,
160 },
161 ItemSpec {
162 title: "Stem Pack: Strings",
163 description: "Isolated string stems from the restoration, for \
164 sampling and study. WAV, ready for your DAW.",
165 item_type: ItemType::Sample,
166 pricing: ItemPricing::Pwyw { min: 200 },
167 tags: &["audio.format.samples", "audio.technique.sampling"],
168 body: None,
169 },
170 ItemSpec {
171 title: "Session Take (Video)",
172 description: "A short archival video of the session, restored \
173 and captioned.",
174 item_type: ItemType::Video,
175 pricing: ItemPricing::Pwyw { min: 100 },
176 tags: &["video", "video.genre.music-video"],
177 body: None,
178 },
179 ],
180 },
181 },
182 CreatorSpec {
183 handle: "deskriver",
184 display_name: "Deskriver Tools",
185 bio: "Source-available desktop tools that stay out of your way. No \
186 accounts required to run them, no engagement tricks, no cloud \
187 lock-in.",
188 project: ProjectSpec {
189 slug: "deskriver-suite",
190 blog: &[
191 BlogSpec {
192 title: "One download, buy it once",
193 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.",
194 },
195 BlogSpec {
196 title: "Why the tools stay offline",
197 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.",
198 },
199 ],
200 title: "Deskriver Suite",
201 description: "A bundle of small, source-available desktop utilities. \
202 Runs offline with no account required, stores \
203 everything locally, and stays out of your way. One \
204 download, buy it once.",
205 features: &["downloads", "license_keys"],
206 pricing: ProjectPricing::BuyOnce(900),
207 tiers: &[],
208 items: &[
209 ItemSpec {
210 title: "Deskriver Focus (Plugin)",
211 description: "A focus-timer plugin for the suite. Signed \
212 builds, offline, no telemetry.",
213 item_type: ItemType::Plugin,
214 pricing: ItemPricing::BuyOnce(1200),
215 tags: &["software.format.plugin", "software.format.vst3"],
216 body: None,
217 },
218 ItemSpec {
219 title: "Minimal Preset Pack",
220 description: "A set of restrained presets for the suite — \
221 sensible defaults, nothing flashy.",
222 item_type: ItemType::Preset,
223 pricing: ItemPricing::BuyOnce(500),
224 tags: &["software", "software.platform.macos"],
225 body: None,
226 },
227 ItemSpec {
228 title: "Weekly-Review Template",
229 description: "A ready-to-use weekly-review layout. Import it \
230 and adapt it to your week.",
231 item_type: ItemType::Template,
232 pricing: ItemPricing::BuyOnce(300),
233 tags: &["writing.topic.productivity", "software"],
234 body: None,
235 },
236 ItemSpec {
237 title: "Deskriver Utility (Download)",
238 description: "The core command-line utility. Signed and \
239 notarized for macOS; runs fully offline.",
240 item_type: ItemType::Digital,
241 pricing: ItemPricing::BuyOnce(900),
242 tags: &[
243 "software.format.cli",
244 "software.platform.macos",
245 "software.format.desktop",
246 ],
247 body: None,
248 },
249 ],
250 },
251 },
252 CreatorSpec {
253 handle: "stillfield",
254 display_name: "Stillfield",
255 bio: "Quiet landscape and still-life photography, released to the public \
256 domain. Download, print, remix — no permission needed.",
257 project: ProjectSpec {
258 slug: "cc0-field-library",
259 blog: &[
260 BlogSpec {
261 title: "Opening the field library",
262 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.",
263 },
264 BlogSpec {
265 title: "Why these are CC0",
266 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.",
267 },
268 ],
269 title: "CC0 Field Library",
270 description: "A growing library of landscape and still-life \
271 photography released to the public domain. Download the \
272 full-resolution files and use them however you like, no \
273 attribution required.",
274 features: &["downloads"],
275 pricing: ProjectPricing::Free,
276 tiers: &[],
277 items: &[ItemSpec {
278 title: "Field Study 01 (Print)",
279 description: "A high-resolution landscape study, released CC0. \
280 Free to download, print, and remix.",
281 item_type: ItemType::Image,
282 pricing: ItemPricing::Free,
283 tags: &["visual.medium.photography", "visual"],
284 body: None,
285 }],
286 },
287 },
288 CreatorSpec {
289 handle: "marginalia",
290 display_name: "Marginalia Press",
291 bio: "A one-person press typesetting public-domain literature into clean, \
292 readable editions. New volumes for subscribers each month.",
293 project: ProjectSpec {
294 slug: "the-marginalia-reader",
295 blog: &[
296 BlogSpec {
297 title: "The first monthly volume",
298 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.",
299 },
300 BlogSpec {
301 title: "On typesetting the commons",
302 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.",
303 },
304 ],
305 title: "The Marginalia Reader",
306 description: "Public-domain literature, re-typeset into clean and \
307 readable editions. Subscribers get a new volume every \
308 month, delivered as EPUB and PDF.",
309 features: &["text", "blog", "subscriptions"],
310 pricing: ProjectPricing::Subscription,
311 tiers: &[
312 TierSpec {
313 name: "Reader",
314 description: "The monthly edition, EPUB and PDF.",
315 price_cents: 300,
316 },
317 TierSpec {
318 name: "Patron",
319 description: "The monthly edition plus the working notes and \
320 early proofs.",
321 price_cents: 600,
322 },
323 TierSpec {
324 name: "Benefactor",
325 description: "Everything, plus a printed copy of the annual \
326 collection.",
327 price_cents: 1200,
328 },
329 ],
330 items: &[
331 ItemSpec {
332 title: "On Slow Reading",
333 description: "The opening essay of the reader — an argument \
334 for reading twice.",
335 item_type: ItemType::Text,
336 pricing: ItemPricing::Free,
337 tags: &["writing.format.essay", "writing.topic.creativity"],
338 body: Some(SLOW_READING_BODY),
339 },
340 ItemSpec {
341 title: "Typesetting the Commons",
342 description: "A short course on preparing public-domain texts \
343 as clean digital editions.",
344 item_type: ItemType::Course,
345 pricing: ItemPricing::Free,
346 tags: &["education.format.course", "education.topic.writing"],
347 body: None,
348 },
349 ],
350 },
351 },
352 CreatorSpec {
353 handle: "commonshare",
354 display_name: "Commonshare",
355 bio: "A benefit account: everything here is free, and any support routed \
356 through it passes straight to community projects. Value to the \
357 commons, not to us.",
358 project: ProjectSpec {
359 slug: "commons-sampler",
360 blog: &[
361 BlogSpec {
362 title: "What the account funds",
363 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.",
364 },
365 BlogSpec {
366 title: "All of this is free",
367 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.",
368 },
369 ],
370 title: "Commons Sampler",
371 description: "A free sampler of work shared through the Commonshare \
372 benefit account. Everything here is free to download; \
373 anything routed through the account passes straight to \
374 community projects.",
375 features: &["downloads"],
376 pricing: ProjectPricing::Free,
377 tiers: &[],
378 items: &[ItemSpec {
379 title: "Community Bundle Vol. 1",
380 description: "A free bundle collecting highlights shared through \
381 the account — music and art, all freely licensed.",
382 item_type: ItemType::Bundle,
383 pricing: ItemPricing::Free,
384 tags: &["audio", "visual"],
385 body: None,
386 }],
387 },
388 },
389 ];
390
391 /// Create every roster creator: a durable `@example.test` account with a profile.
392 ///
393 /// Returns the created accounts paired with their project specs so
394 /// [`super::projects::seed_projects`] can build one project per creator. Called
395 /// only from [`super::run`], after its prod-safety guards and the example-data
396 /// reset. Idempotency comes from that reset (prior example accounts are wiped
397 /// first), so this always inserts fresh rows.
398 pub async fn seed_creators(pool: &sqlx::PgPool) -> Result<Vec<SeededCreator>, SeedError> {
399 let mut seeded = Vec::with_capacity(ROSTER.len());
400 for spec in ROSTER {
401 // Never-login account: hash a random string (the sandbox pattern). The
402 // stored hash is a valid Argon2id hash no one holds the input to.
403 let password_hash =
404 auth::hash_password_async(format!("example-seed_{}", Uuid::new_v4())).await?;
405 let username = Username::from_trusted(spec.handle.to_string());
406 let email = Email::from_trusted(format!("{}@{EXAMPLE_EMAIL_DOMAIN}", spec.handle));
407
408 let user =
409 db::users::create_example_creator(pool, &username, &email, &password_hash).await?;
410 let user =
411 db::users::update_user_profile(pool, user.id, Some(spec.display_name), Some(spec.bio))
412 .await?;
413
414 tracing::info!(
415 handle = spec.handle,
416 user_id = %user.id,
417 "example seed: created creator"
418 );
419 seeded.push(SeededCreator {
420 user,
421 project: &spec.project,
422 });
423 }
424 Ok(seeded)
425 }
426