Skip to main content

max / makenotwork

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