Skip to main content

max / makenotwork

34.3 KB · 748 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 /// Manifest id of the item's primary file (`media-manifest.toml`). `None`
69 /// for item types that serve a generated blob rather than a real file, and
70 /// for Image items, whose cover *is* the work. An id that is declared but
71 /// uncurated falls back to the generated placeholder.
72 pub media: Option<&'static str>,
73 /// Manifest id of the item's cover art. `None` keeps the grey placeholder.
74 pub cover: Option<&'static str>,
75 }
76
77 /// The single project a seeded creator owns, plus its content.
78 pub struct ProjectSpec {
79 /// URL slug (auto-suffixed on the rare collision by `create_project`).
80 pub slug: &'static str,
81 /// Public project title.
82 pub title: &'static str,
83 /// Project description (re-skinned from `content_seed.md`, no real-app names).
84 pub description: &'static str,
85 /// Enabled features; `create_project` derives `project_type` from these.
86 pub features: &'static [&'static str],
87 /// Headline pricing model for the project.
88 ///
89 /// Keep this `Free` unless the point of the project *is* the paywall.
90 /// A non-free project renders `ProjectPaywallTemplate` to anyone without
91 /// access, and that template lists no items at all
92 /// (`Project::from_db(db_project, 0)`). testnot is a public no-login demo,
93 /// so every visitor is "without access" forever: a paid project there is a
94 /// storefront nobody can see inside. Spreading the four `PricingKind`s
95 /// across projects hid 9 of the 11 seeded items this way until 2026-08-05.
96 ///
97 /// Price the *items* instead. Item pricing is visible from the storefront
98 /// (the card links to `/purchase/{id}`), so BuyOnce and Pwyw demonstrate
99 /// themselves without closing the door. `Subscription` is the exception: it
100 /// is project-level by construction, so marginalia keeps it and is the one
101 /// paywall on the box.
102 pub pricing: ProjectPricing,
103 /// Subscription tiers (only non-empty for a `Subscription` project).
104 pub tiers: &'static [TierSpec],
105 /// Items to seed under this project.
106 pub items: &'static [ItemSpec],
107 /// Blog posts to publish on this project (Phase 4).
108 pub blog: &'static [BlogSpec],
109 /// Manifest id of the project's cover art (`media-manifest.toml`). `None`
110 /// keeps the grey placeholder.
111 pub cover: Option<&'static str>,
112 }
113
114 /// One fabricated creator plus the project they own.
115 struct CreatorSpec {
116 /// Login handle and local-part of `{handle}@example.test`.
117 handle: &'static str,
118 /// Display name shown on the profile.
119 display_name: &'static str,
120 /// Short bio, matter-of-fact, no pomp, no real-app names.
121 bio: &'static str,
122 /// The project this creator owns.
123 project: ProjectSpec,
124 }
125
126 /// A creator after insertion, paired with the project spec still to be seeded.
127 pub struct SeededCreator {
128 /// The durable, publicly-visible creator row (`is_sandbox = FALSE`).
129 pub user: DbUser,
130 /// The project [`super::projects::seed_projects`] will create for this user.
131 pub project: &'static ProjectSpec,
132 }
133
134 /// A short original essay body for the Text item (generic, public-domain-flavored
135 /// prose written for the seed, not copied from any real work).
136 const SLOW_READING_BODY: &str = "\
137 There is a kind of reading that resists hurry. It asks you to sit with a sentence \
138 until it gives up its second meaning, and then its third.
139
140 We have built our tools for the opposite habit, to skim, to extract, to move on. \
141 This press is a small argument against that. The editions here are set with wide \
142 margins on purpose: room for a pencil, room for a second thought.
143
144 Read slowly. Read the same page twice. The commons is patient, and so are its books.";
145
146 /// Body for "Marginal Notes: On Editions". Original prose, same as above.
147 const ON_EDITIONS_BODY: &str = "\
148 An edition is an argument about how a text should be met. Where the line breaks, \
149 how wide the margin runs, whether a note sits at the foot of the page or is exiled \
150 to the back: each is a claim about what the reader is for.
151
152 The claims we make here are modest. Long measure tires the eye, so the measure is \
153 short. Notes belong where the sentence that needs them is, so they stay on the page. \
154 Nothing is set in a face that wants to be admired.
155
156 A good edition disappears. You should finish it remembering the book.";
157
158 /// Body for "The Reader, No. 1".
159 const READER_ONE_BODY: &str = "\
160 The first number collects three short pieces from the commons, chosen for how they \
161 sit next to each other rather than for any theme.
162
163 The oldest is a traveller's account, written by someone with no gift for landscape \
164 and an unusual ear for how people speak. The second is a letter that was never sent. \
165 The third is a fragment, and it stops where the manuscript does.
166
167 Fragments are worth setting properly. A text that breaks off mid-thought is not a \
168 failure of the text.";
169
170 /// Body for "The Reader, No. 2".
171 const READER_TWO_BODY: &str = "\
172 Two essays this month, both on work, both by people who did not think of themselves \
173 as writers.
174
175 The first was set down by a printer late in a long career, and reads like someone \
176 explaining a trade to a nephew who has not asked. The second is angrier and shorter, \
177 and was published anonymously, which was the ordinary caution of the time.
178
179 Neither has been edited for taste. The spelling is regularised and nothing else.";
180
181 /// The five content creators. Their projects span all four `PricingKind`s and,
182 /// across their items, every `ItemType`. See the sprint doc
183 /// `_private/docs/mnw/testnot-example-seed.md`.
184 const ROSTER: &[CreatorSpec] = &[
185 CreatorSpec {
186 handle: "openreels",
187 display_name: "Open Reels",
188 bio: "A small label restoring and reissuing public-domain recordings. \
189 Every release is free to share; pay what it's worth if it moves you.",
190 project: ProjectSpec {
191 slug: "restored-reels-vol-1",
192 cover: Some("openreels-project-cover"),
193 blog: &[
194 BlogSpec {
195 title: "Vol. 1 is out",
196 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.",
197 },
198 BlogSpec {
199 title: "How we restore a reel",
200 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.",
201 },
202 ],
203 title: "Restored Reels, Vol. 1",
204 description: "A first volume of public-domain recordings, cleaned up \
205 and remastered for easy listening. Free to stream and \
206 share; name your price if you'd like to support the \
207 restoration work.",
208 features: &["audio"],
209 // Free at the project level so the storefront is browsable; the Pwyw
210 // demonstration lives on the items below. See the pricing note on
211 // `ProjectSpec::pricing`.
212 pricing: ProjectPricing::Free,
213 tiers: &[],
214 items: &[
215 ItemSpec {
216 title: "Restoration No. 1 (Full Mix)",
217 description: "The complete restored recording, remastered from \
218 a public-domain source. Name your price.",
219 item_type: ItemType::Audio,
220 pricing: ItemPricing::Pwyw { min: 100 },
221 tags: &["audio", "audio.format.music"],
222 body: None,
223 media: Some("restoration-1-audio"),
224 cover: Some("restoration-1-cover"),
225 },
226 ItemSpec {
227 title: "Stem Pack: Strings",
228 description: "Isolated string stems from the restoration, for \
229 sampling and study. WAV, ready for your DAW.",
230 item_type: ItemType::Sample,
231 pricing: ItemPricing::Pwyw { min: 200 },
232 tags: &["audio.format.samples", "audio.technique.sampling"],
233 body: None,
234 media: Some("stem-pack-strings-audio"),
235 cover: Some("stem-pack-strings-cover"),
236 },
237 ItemSpec {
238 title: "Session Take (Video)",
239 description: "A short archival video of the session, restored \
240 and captioned.",
241 item_type: ItemType::Video,
242 pricing: ItemPricing::Pwyw { min: 100 },
243 tags: &["video", "video.genre.music-video"],
244 body: None,
245 media: Some("session-take-video"),
246 cover: Some("session-take-cover"),
247 },
248 ItemSpec {
249 title: "Restoration No. 2 (Full Mix)",
250 description: "The second restoration in the volume, from a \
251 later reel. Name your price.",
252 item_type: ItemType::Audio,
253 pricing: ItemPricing::Pwyw { min: 100 },
254 tags: &["audio", "audio.format.music"],
255 body: None,
256 media: Some("restoration-2-audio"),
257 cover: Some("restoration-2-cover"),
258 },
259 ItemSpec {
260 title: "Stem Pack: Brass",
261 description: "Isolated brass stems from the second restoration. \
262 WAV, ready for your DAW.",
263 item_type: ItemType::Sample,
264 pricing: ItemPricing::Pwyw { min: 200 },
265 tags: &["audio.format.samples", "audio.technique.sampling"],
266 body: None,
267 media: None,
268 cover: Some("stem-pack-brass-cover"),
269 },
270 ],
271 },
272 },
273 CreatorSpec {
274 handle: "deskriver",
275 display_name: "Deskriver Tools",
276 bio: "Source-available desktop tools that stay out of your way. No \
277 accounts required to run them, no engagement tricks, no cloud \
278 lock-in.",
279 project: ProjectSpec {
280 slug: "deskriver-suite",
281 cover: Some("deskriver-project-cover"),
282 blog: &[
283 BlogSpec {
284 title: "One download, buy it once",
285 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.",
286 },
287 BlogSpec {
288 title: "Why the tools stay offline",
289 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.",
290 },
291 ],
292 title: "Deskriver Suite",
293 description: "A bundle of small, source-available desktop utilities. \
294 Runs offline with no account required, stores \
295 everything locally, and stays out of your way. One \
296 download, buy it once.",
297 features: &["downloads", "license_keys"],
298 // Free at the project level so the storefront is browsable; the four
299 // items below are BuyOnce at $3-$12 and carry the paid demonstration.
300 // See the pricing note on `ProjectSpec::pricing`.
301 pricing: ProjectPricing::Free,
302 tiers: &[],
303 items: &[
304 ItemSpec {
305 title: "Deskriver Focus (Plugin)",
306 description: "A focus-timer plugin for the suite. Signed \
307 builds, offline, no telemetry.",
308 item_type: ItemType::Plugin,
309 pricing: ItemPricing::BuyOnce(1200),
310 tags: &["software.format.plugin", "software.format.vst3"],
311 body: None,
312 media: None,
313 cover: Some("deskriver-focus-cover"),
314 },
315 ItemSpec {
316 title: "Minimal Preset Pack",
317 description: "A set of restrained presets for the suite, \
318 sensible defaults, nothing flashy.",
319 item_type: ItemType::Preset,
320 pricing: ItemPricing::BuyOnce(500),
321 tags: &["software", "software.platform.macos"],
322 body: None,
323 media: None,
324 cover: Some("deskriver-presets-cover"),
325 },
326 ItemSpec {
327 title: "Weekly-Review Template",
328 description: "A ready-to-use weekly-review layout. Import it \
329 and adapt it to your week.",
330 item_type: ItemType::Template,
331 pricing: ItemPricing::BuyOnce(300),
332 tags: &["writing.topic.productivity", "software"],
333 body: None,
334 media: None,
335 cover: Some("deskriver-template-cover"),
336 },
337 ItemSpec {
338 title: "Deskriver Utility (Download)",
339 description: "The core command-line utility. Signed and \
340 notarized for macOS; runs fully offline.",
341 item_type: ItemType::Digital,
342 pricing: ItemPricing::BuyOnce(900),
343 tags: &[
344 "software.format.cli",
345 "software.platform.macos",
346 "software.format.desktop",
347 ],
348 body: None,
349 media: None,
350 cover: Some("deskriver-utility-cover"),
351 },
352 ItemSpec {
353 title: "Deskriver Notes (Plugin)",
354 description: "A plain-text notes panel for the suite. Local \
355 files, no database, no sync.",
356 item_type: ItemType::Plugin,
357 pricing: ItemPricing::BuyOnce(800),
358 tags: &["software.format.plugin", "software.platform.macos"],
359 body: None,
360 media: None,
361 cover: Some("deskriver-notes-cover"),
362 },
363 ],
364 },
365 },
366 CreatorSpec {
367 handle: "stillfield",
368 display_name: "Stillfield",
369 bio: "Quiet landscape and still-life photography, released to the public \
370 domain. Download, print, remix, no permission needed.",
371 project: ProjectSpec {
372 slug: "cc0-field-library",
373 cover: Some("stillfield-project-cover"),
374 blog: &[
375 BlogSpec {
376 title: "Opening the field library",
377 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.",
378 },
379 BlogSpec {
380 title: "Why these are CC0",
381 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.",
382 },
383 ],
384 title: "CC0 Field Library",
385 description: "A growing library of landscape and still-life \
386 photography released to the public domain. Download the \
387 full-resolution files and use them however you like, no \
388 attribution required.",
389 features: &["downloads"],
390 pricing: ProjectPricing::Free,
391 tiers: &[],
392 items: &[
393 ItemSpec {
394 title: "Field Study 01 (Print)",
395 description: "A high-resolution landscape study, released CC0. \
396 Free to download, print, and remix.",
397 item_type: ItemType::Image,
398 pricing: ItemPricing::Free,
399 tags: &["visual.medium.photography", "visual"],
400 body: None,
401 media: None,
402 cover: Some("field-study-01-cover"),
403 },
404 ItemSpec {
405 title: "Field Study 02 (Print)",
406 description: "A second landscape study from the same series. \
407 Full resolution, CC0.",
408 item_type: ItemType::Image,
409 pricing: ItemPricing::Free,
410 tags: &["visual.medium.photography", "visual"],
411 body: None,
412 media: None,
413 cover: Some("field-study-02-cover"),
414 },
415 ItemSpec {
416 title: "Field Study 03 (Print)",
417 description: "Late light over open ground. Full resolution, \
418 CC0, no attribution required.",
419 item_type: ItemType::Image,
420 pricing: ItemPricing::Free,
421 tags: &["visual.medium.photography", "visual"],
422 body: None,
423 media: None,
424 cover: Some("field-study-03-cover"),
425 },
426 ItemSpec {
427 title: "Still Life 01 (Print)",
428 description: "A quiet interior arrangement, shot in daylight. \
429 Full resolution, CC0.",
430 item_type: ItemType::Image,
431 pricing: ItemPricing::Free,
432 tags: &["visual.medium.photography", "visual"],
433 body: None,
434 media: None,
435 cover: Some("still-life-01-cover"),
436 },
437 ItemSpec {
438 title: "Still Life 02 (Print)",
439 description: "The same table, a different hour. Full \
440 resolution, CC0.",
441 item_type: ItemType::Image,
442 pricing: ItemPricing::Free,
443 tags: &["visual.medium.photography", "visual"],
444 body: None,
445 media: None,
446 cover: Some("still-life-02-cover"),
447 },
448 ],
449 },
450 },
451 CreatorSpec {
452 handle: "marginalia",
453 display_name: "Marginalia Press",
454 bio: "A one-person press typesetting public-domain literature into clean, \
455 readable editions. New volumes for subscribers each month.",
456 project: ProjectSpec {
457 slug: "the-marginalia-reader",
458 cover: Some("marginalia-project-cover"),
459 blog: &[
460 BlogSpec {
461 title: "The first monthly volume",
462 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.",
463 },
464 BlogSpec {
465 title: "On typesetting the commons",
466 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.",
467 },
468 ],
469 title: "The Marginalia Reader",
470 description: "Public-domain literature, re-typeset into clean and \
471 readable editions. Subscribers get a new volume every \
472 month, delivered as EPUB and PDF.",
473 features: &["text", "blog", "subscriptions"],
474 pricing: ProjectPricing::Subscription,
475 tiers: &[
476 TierSpec {
477 name: "Reader",
478 description: "The monthly edition, EPUB and PDF.",
479 price_cents: 300,
480 },
481 TierSpec {
482 name: "Patron",
483 description: "The monthly edition plus the working notes and \
484 early proofs.",
485 price_cents: 600,
486 },
487 TierSpec {
488 name: "Benefactor",
489 description: "Everything, plus a printed copy of the annual \
490 collection.",
491 price_cents: 1200,
492 },
493 ],
494 items: &[
495 ItemSpec {
496 title: "On Slow Reading",
497 description: "The opening essay of the reader, an argument \
498 for reading twice.",
499 item_type: ItemType::Text,
500 pricing: ItemPricing::Free,
501 tags: &["writing.format.essay", "writing.topic.creativity"],
502 body: Some(SLOW_READING_BODY),
503 media: None,
504 cover: Some("on-slow-reading-cover"),
505 },
506 ItemSpec {
507 title: "Typesetting the Commons",
508 description: "A short course on preparing public-domain texts \
509 as clean digital editions.",
510 item_type: ItemType::Course,
511 pricing: ItemPricing::Free,
512 tags: &["education.format.course", "education.topic.writing"],
513 body: None,
514 media: None,
515 cover: Some("typesetting-commons-cover"),
516 },
517 ItemSpec {
518 title: "The Reader, No. 1",
519 description: "The first monthly number: three short pieces from \
520 the commons, newly set.",
521 item_type: ItemType::Text,
522 pricing: ItemPricing::Free,
523 tags: &["writing.format.essay", "writing"],
524 body: Some(READER_ONE_BODY),
525 media: None,
526 cover: Some("reader-one-cover"),
527 },
528 ItemSpec {
529 title: "The Reader, No. 2",
530 description: "Two essays on work, by people who did not think of \
531 themselves as writers.",
532 item_type: ItemType::Text,
533 pricing: ItemPricing::Free,
534 tags: &["writing.format.essay", "writing"],
535 body: Some(READER_TWO_BODY),
536 media: None,
537 cover: Some("reader-two-cover"),
538 },
539 ItemSpec {
540 title: "Marginal Notes: On Editions",
541 description: "What a margin, a measure, and a footnote each \
542 claim about the reader.",
543 item_type: ItemType::Text,
544 pricing: ItemPricing::Free,
545 tags: &["writing.format.essay", "writing.topic.creativity"],
546 body: Some(ON_EDITIONS_BODY),
547 media: None,
548 cover: Some("on-editions-cover"),
549 },
550 ],
551 },
552 },
553 CreatorSpec {
554 handle: "commonshare",
555 display_name: "Commonshare",
556 bio: "A benefit account: everything here is free, and any support routed \
557 through it passes straight to community projects. Value to the \
558 commons, not to us.",
559 project: ProjectSpec {
560 slug: "commons-sampler",
561 cover: Some("commonshare-project-cover"),
562 blog: &[
563 BlogSpec {
564 title: "What the account funds",
565 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.",
566 },
567 BlogSpec {
568 title: "All of this is free",
569 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.",
570 },
571 ],
572 title: "Commons Sampler",
573 description: "A free sampler of work shared through the Commonshare \
574 benefit account. Everything here is free to download; \
575 anything routed through the account passes straight to \
576 community projects.",
577 features: &["downloads"],
578 pricing: ProjectPricing::Free,
579 tiers: &[],
580 items: &[
581 ItemSpec {
582 title: "Community Bundle Vol. 1",
583 description: "A free bundle collecting highlights shared through \
584 the account, music and art, all freely licensed.",
585 item_type: ItemType::Bundle,
586 pricing: ItemPricing::Free,
587 tags: &["audio", "visual"],
588 body: None,
589 media: None,
590 cover: Some("community-bundle-cover"),
591 },
592 ItemSpec {
593 title: "Community Bundle Vol. 2",
594 description: "The second collection. Same terms: free to \
595 download, free to pass on.",
596 item_type: ItemType::Bundle,
597 pricing: ItemPricing::Free,
598 tags: &["audio", "visual"],
599 body: None,
600 media: None,
601 cover: Some("community-bundle-2-cover"),
602 },
603 ItemSpec {
604 title: "Sampler: Field Recordings",
605 description: "Room tone, weather, and open ground. Freely \
606 licensed, for anything you like.",
607 item_type: ItemType::Sample,
608 pricing: ItemPricing::Free,
609 tags: &["audio.format.samples", "audio"],
610 body: None,
611 media: None,
612 cover: Some("field-recordings-cover"),
613 },
614 ItemSpec {
615 title: "Sampler: Public-Domain Loops",
616 description: "Short loops cut from public-domain recordings, \
617 tempo-labelled and ready to drop in.",
618 item_type: ItemType::Sample,
619 pricing: ItemPricing::Free,
620 tags: &["audio.format.samples", "audio.technique.sampling"],
621 body: None,
622 media: None,
623 cover: Some("pd-loops-cover"),
624 },
625 ItemSpec {
626 title: "Commons Reader (Download)",
627 description: "A collected PDF of the writing shared through the \
628 account this year.",
629 item_type: ItemType::Digital,
630 pricing: ItemPricing::Free,
631 tags: &["writing", "writing.format.essay"],
632 body: None,
633 media: None,
634 cover: Some("commons-reader-cover"),
635 },
636 ],
637 },
638 },
639 ];
640
641 /// Create every roster creator: a durable `@example.test` account with a profile.
642 ///
643 /// Returns the created accounts paired with their project specs so
644 /// [`super::projects::seed_projects`] can build one project per creator. Called
645 /// only from [`super::run`], after its prod-safety guards and the example-data
646 /// reset. Idempotency comes from that reset (prior example accounts are wiped
647 /// first), so this always inserts fresh rows.
648 pub async fn seed_creators(pool: &sqlx::PgPool) -> Result<Vec<SeededCreator>, SeedError> {
649 let mut seeded = Vec::with_capacity(ROSTER.len());
650 for spec in ROSTER {
651 // Never-login account: hash a random string (the sandbox pattern). The
652 // stored hash is a valid Argon2id hash no one holds the input to.
653 let password_hash =
654 auth::hash_password_async(format!("example-seed_{}", Uuid::new_v4())).await?;
655 let username = Username::from_trusted(spec.handle.to_string());
656 let email = Email::from_trusted(format!("{}@{EXAMPLE_EMAIL_DOMAIN}", spec.handle));
657
658 let user =
659 db::users::create_example_creator(pool, &username, &email, &password_hash).await?;
660 let user =
661 db::users::update_user_profile(pool, user.id, Some(spec.display_name), Some(spec.bio))
662 .await?;
663
664 tracing::info!(
665 handle = spec.handle,
666 user_id = %user.id,
667 "example seed: created creator"
668 );
669 seeded.push(SeededCreator {
670 user,
671 project: &spec.project,
672 });
673 }
674 Ok(seeded)
675 }
676
677 #[cfg(test)]
678 mod tests {
679 use super::*;
680 use std::collections::HashSet;
681
682 /// Every manifest id the roster names.
683 fn referenced_ids() -> Vec<&'static str> {
684 let mut ids = Vec::new();
685 for creator in ROSTER {
686 ids.extend(creator.project.cover);
687 for item in creator.project.items {
688 ids.extend(item.media);
689 ids.extend(item.cover);
690 }
691 }
692 ids
693 }
694
695 /// The roster and the manifest have to agree, in both directions. A typo in
696 /// either one is otherwise silent: the slot keeps its grey placeholder and
697 /// the box looks merely uncurated rather than broken.
698 #[test]
699 fn roster_and_manifest_agree() {
700 let manifest =
701 super::super::manifest::Manifest::load().expect("the manifest must be loadable");
702 let declared: HashSet<&str> = manifest.ids().collect();
703 let referenced: HashSet<&str> = referenced_ids().into_iter().collect();
704
705 let undeclared: Vec<&str> = referenced.difference(&declared).copied().collect();
706 assert!(
707 undeclared.is_empty(),
708 "roster references ids the manifest does not declare: {undeclared:?}"
709 );
710
711 let unused: Vec<&str> = declared.difference(&referenced).copied().collect();
712 assert!(
713 unused.is_empty(),
714 "manifest declares ids nothing references; curating them would upload \
715 files no page shows: {unused:?}"
716 );
717 }
718
719 /// Two slots may share an asset, but not an id by accident: a duplicate here
720 /// usually means a copy-paste, not a deliberate reuse.
721 #[test]
722 fn each_id_is_referenced_once() {
723 let ids = referenced_ids();
724 let mut seen = HashSet::with_capacity(ids.len());
725 for id in ids {
726 assert!(seen.insert(id), "asset id {id:?} is referenced twice");
727 }
728 }
729
730 /// Image items serve their cover as the work, so a `media` id on one would
731 /// upload a file nothing links to.
732 #[test]
733 fn image_items_carry_no_separate_media() {
734 for creator in ROSTER {
735 for item in creator.project.items {
736 if matches!(item.item_type, ItemType::Image | ItemType::Text) {
737 assert!(
738 item.media.is_none(),
739 "{:?} item {:?} declares media, which nothing uploads",
740 item.item_type,
741 item.title
742 );
743 }
744 }
745 }
746 }
747 }
748