Skip to main content

max / makenotwork

49.5 KB · 1543 lines History Blame Raw
1 //! Strongly-typed domain enums that replace stringly-typed database columns.
2 //!
3 //! Each enum uses manual sqlx `Type`/`Encode`/`Decode` impls (via `String`)
4 //! so it works with both VARCHAR and TEXT columns. Plus `Serialize`/`Deserialize`
5 //! for JSON and form parsing.
6
7 use serde::{Deserialize, Serialize};
8
9 /// Generate `Display`, `FromStr`, and sqlx `Type`/`Encode`/`Decode` impls
10 /// for a simple enum ↔ string mapping. The sqlx impls delegate to `String`
11 /// so the enum is compatible with any text-like column (TEXT, VARCHAR, etc.).
12 macro_rules! impl_str_enum {
13 ($enum_name:ident { $($variant:ident => $str:literal),+ $(,)? }) => {
14 impl std::fmt::Display for $enum_name {
15 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16 let s = match self {
17 $( Self::$variant => $str, )+
18 };
19 f.write_str(s)
20 }
21 }
22
23 impl std::str::FromStr for $enum_name {
24 type Err = String;
25
26 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
27 match s {
28 $( $str => Ok(Self::$variant), )+
29 other => Err(format!("invalid {}: {other}", stringify!($enum_name))),
30 }
31 }
32 }
33
34 // sqlx Type: delegate to String so it's compatible with TEXT/VARCHAR.
35 impl sqlx::Type<sqlx::Postgres> for $enum_name {
36 fn type_info() -> sqlx::postgres::PgTypeInfo {
37 <String as sqlx::Type<sqlx::Postgres>>::type_info()
38 }
39
40 fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool {
41 <String as sqlx::Type<sqlx::Postgres>>::compatible(ty)
42 }
43 }
44
45 // sqlx Encode: write the Display string.
46 impl sqlx::Encode<'_, sqlx::Postgres> for $enum_name {
47 fn encode_by_ref(
48 &self,
49 buf: &mut sqlx::postgres::PgArgumentBuffer,
50 ) -> Result<sqlx::encode::IsNull, Box<dyn std::error::Error + Send + Sync>> {
51 <String as sqlx::Encode<'_, sqlx::Postgres>>::encode(self.to_string(), buf)
52 }
53 }
54
55 // sqlx Decode: parse the string value via FromStr.
56 impl sqlx::Decode<'_, sqlx::Postgres> for $enum_name {
57 fn decode(
58 value: sqlx::postgres::PgValueRef<'_>,
59 ) -> std::result::Result<Self, Box<dyn std::error::Error + Send + Sync>> {
60 let s = <String as sqlx::Decode<'_, sqlx::Postgres>>::decode(value)?;
61 Ok(s.parse::<Self>()?)
62 }
63 }
64
65 // Allow comparison with string slices (useful in Askama templates).
66 impl PartialEq<&str> for $enum_name {
67 fn eq(&self, other: &&str) -> bool {
68 let s: &str = match self {
69 $( Self::$variant => $str, )+
70 };
71 s == *other
72 }
73 }
74
75 impl PartialEq<str> for $enum_name {
76 fn eq(&self, other: &str) -> bool {
77 let s: &str = match self {
78 $( Self::$variant => $str, )+
79 };
80 s == other
81 }
82 }
83 };
84 }
85
86 // ── Discount codes ──
87
88 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89 #[serde(rename_all = "lowercase")]
90 pub enum DiscountType {
91 Percentage,
92 Fixed,
93 }
94
95 impl_str_enum!(DiscountType {
96 Percentage => "percentage",
97 Fixed => "fixed",
98 });
99
100 // ── Promo codes ──
101
102 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103 #[serde(rename_all = "snake_case")]
104 pub enum CodePurpose {
105 Discount,
106 FreeAccess,
107 FreeTrial,
108 }
109
110 impl_str_enum!(CodePurpose {
111 Discount => "discount",
112 FreeAccess => "free_access",
113 FreeTrial => "free_trial",
114 });
115
116 // ── Waitlist ──
117
118 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
119 #[serde(rename_all = "lowercase")]
120 pub enum WaitlistStatus {
121 Pending,
122 Approved,
123 Spam,
124 }
125
126 impl_str_enum!(WaitlistStatus {
127 Pending => "pending",
128 Approved => "approved",
129 Spam => "spam",
130 });
131
132 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
133 pub enum SelectionMethod {
134 #[serde(rename = "hand_picked")]
135 HandPicked,
136 #[serde(rename = "lottery")]
137 Lottery,
138 #[serde(rename = "invited")]
139 Invited,
140 }
141
142 impl_str_enum!(SelectionMethod {
143 HandPicked => "hand_picked",
144 Lottery => "lottery",
145 Invited => "invited",
146 });
147
148 // ── Transactions ──
149
150 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
151 #[serde(rename_all = "lowercase")]
152 pub enum TransactionStatus {
153 Pending,
154 Completed,
155 Refunded,
156 }
157
158 impl_str_enum!(TransactionStatus {
159 Pending => "pending",
160 Completed => "completed",
161 Refunded => "refunded",
162 });
163
164 // ── Follows ──
165
166 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
167 #[serde(rename_all = "lowercase")]
168 pub enum FollowTargetType {
169 User,
170 Project,
171 Tag,
172 }
173
174 impl_str_enum!(FollowTargetType {
175 User => "user",
176 Project => "project",
177 Tag => "tag",
178 });
179
180 // ── Subscriptions ──
181
182 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
183 pub enum SubscriptionStatus {
184 #[serde(rename = "active")]
185 Active,
186 #[serde(rename = "trialing")]
187 Trialing,
188 #[serde(rename = "incomplete")]
189 Incomplete,
190 #[serde(rename = "incomplete_expired")]
191 IncompleteExpired,
192 #[serde(rename = "past_due")]
193 PastDue,
194 #[serde(rename = "canceled")]
195 Canceled,
196 #[serde(rename = "unpaid")]
197 Unpaid,
198 }
199
200 impl_str_enum!(SubscriptionStatus {
201 Active => "active",
202 Trialing => "trialing",
203 Incomplete => "incomplete",
204 IncompleteExpired => "incomplete_expired",
205 PastDue => "past_due",
206 Canceled => "canceled",
207 Unpaid => "unpaid",
208 });
209
210 // ── Git repository visibility ──
211
212 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
213 #[serde(rename_all = "lowercase")]
214 pub enum Visibility {
215 Public,
216 Unlisted,
217 Private,
218 }
219
220 impl_str_enum!(Visibility {
221 Public => "public",
222 Unlisted => "unlisted",
223 Private => "private",
224 });
225
226 // ── Project member roles ──
227
228 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
229 #[serde(rename_all = "lowercase")]
230 pub enum ProjectRole {
231 Owner,
232 Member,
233 }
234
235 impl_str_enum!(ProjectRole {
236 Owner => "owner",
237 Member => "member",
238 });
239
240 // ── SyncKit ──
241
242 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
243 pub enum SyncOperation {
244 #[serde(rename = "INSERT")]
245 Insert,
246 #[serde(rename = "UPDATE")]
247 Update,
248 #[serde(rename = "DELETE")]
249 Delete,
250 }
251
252 impl_str_enum!(SyncOperation {
253 Insert => "INSERT",
254 Update => "UPDATE",
255 Delete => "DELETE",
256 });
257
258 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
259 #[serde(rename_all = "lowercase")]
260 pub enum SyncPlatform {
261 Macos,
262 Ios,
263 Android,
264 Windows,
265 Linux,
266 Web,
267 }
268
269 impl_str_enum!(SyncPlatform {
270 Macos => "macos",
271 Ios => "ios",
272 Android => "android",
273 Windows => "windows",
274 Linux => "linux",
275 Web => "web",
276 });
277
278 // ── File scanning ──
279
280 /// Status of an uploaded file in the scan pipeline.
281 ///
282 /// `Pending` — accepted, waiting in `scan_jobs` queue for a worker.
283 /// `Scanning` — worker has claimed the job and is running the pipeline.
284 /// `Clean` — pipeline completed, no Fail verdicts, no fail-closed Errors.
285 /// `HeldForReview` — pipeline completed with a fail-closed Error, OR the
286 /// uploader is untrusted (every untrusted upload routes to admin review).
287 /// `Quarantined` — pipeline returned a Fail verdict on at least one layer.
288 /// `Error` — pipeline itself crashed (worker exception, S3 fetch failed, etc.).
289 ///
290 /// State machine in `docs/scan-pipeline-audit.md`.
291 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
292 #[serde(rename_all = "snake_case")]
293 pub enum FileScanStatus {
294 Pending,
295 Scanning,
296 Clean,
297 Quarantined,
298 HeldForReview,
299 Error,
300 }
301
302 impl_str_enum!(FileScanStatus {
303 Pending => "pending",
304 Scanning => "scanning",
305 Clean => "clean",
306 Quarantined => "quarantined",
307 HeldForReview => "held_for_review",
308 Error => "error",
309 });
310
311 // ── Content Insertions ──
312
313 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
314 #[serde(rename_all = "snake_case")]
315 pub enum InsertionPosition {
316 PreRoll,
317 MidRoll,
318 PostRoll,
319 }
320
321 impl_str_enum!(InsertionPosition {
322 PreRoll => "pre_roll",
323 MidRoll => "mid_roll",
324 PostRoll => "post_roll",
325 });
326
327 // ── Appeals ──
328
329 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
330 #[serde(rename_all = "lowercase")]
331 pub enum AppealDecision {
332 Approved,
333 Denied,
334 }
335
336 impl_str_enum!(AppealDecision {
337 Approved => "approved",
338 Denied => "denied",
339 });
340
341 // ── Discover sorting ──
342
343 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
344 #[serde(rename_all = "snake_case")]
345 pub enum DiscoverSort {
346 Newest,
347 MostSold,
348 PriceAsc,
349 PriceDesc,
350 }
351
352 impl_str_enum!(DiscoverSort {
353 Newest => "newest",
354 MostSold => "most_sold",
355 PriceAsc => "price_asc",
356 PriceDesc => "price_desc",
357 });
358
359 // ── Items ──
360
361 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
362 #[serde(rename_all = "lowercase")]
363 pub enum ItemType {
364 Audio,
365 Text,
366 Video,
367 Image,
368 Plugin,
369 Preset,
370 Sample,
371 Course,
372 Template,
373 Digital,
374 Bundle,
375 }
376
377 impl_str_enum!(ItemType {
378 Audio => "audio",
379 Text => "text",
380 Video => "video",
381 Image => "image",
382 Plugin => "plugin",
383 Preset => "preset",
384 Sample => "sample",
385 Course => "course",
386 Template => "template",
387 Digital => "digital",
388 Bundle => "bundle",
389 });
390
391 impl ItemType {
392 /// Short human-readable label for display (replaces `helpers::get_item_type_label`).
393 pub fn label(&self) -> &'static str {
394 match self {
395 Self::Audio => "Audio",
396 Self::Text => "Text",
397 Self::Video => "Video",
398 Self::Image => "Image",
399 Self::Plugin => "Plugin",
400 Self::Preset => "Preset",
401 Self::Sample => "Sample",
402 Self::Course => "Course",
403 Self::Template => "Template",
404 Self::Digital => "Digital",
405 Self::Bundle => "Bundle",
406 }
407 }
408
409 /// Which wizard content-input group this type belongs to.
410 ///
411 /// Determines what the content step looks like:
412 /// - `"text"` → Markdown editor
413 /// - `"audio"` → Audio file upload
414 /// - `"video"` → Video file upload
415 /// - `"bundle"` → Item picker for bundle contents
416 /// - `"file"` → Generic file upload
417 pub fn wizard_group(&self) -> &'static str {
418 match self {
419 Self::Text => "text",
420 Self::Audio => "audio",
421 Self::Video => "video",
422 Self::Bundle => "bundle",
423 _ => "file",
424 }
425 }
426 }
427
428 // ── Git Issues ──
429
430 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
431 #[serde(rename_all = "lowercase")]
432 pub enum IssueStatus {
433 Open,
434 Closed,
435 }
436
437 impl_str_enum!(IssueStatus {
438 Open => "open",
439 Closed => "closed",
440 });
441
442 // ── Reports ──
443
444 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
445 #[serde(rename_all = "lowercase")]
446 pub enum ReportTargetType {
447 Project,
448 Item,
449 }
450
451 impl_str_enum!(ReportTargetType {
452 Project => "project",
453 Item => "item",
454 });
455
456 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
457 #[serde(rename_all = "lowercase")]
458 pub enum ReportType {
459 Mislabeled,
460 Spam,
461 Abuse,
462 Infringement,
463 Other,
464 }
465
466 impl_str_enum!(ReportType {
467 Mislabeled => "mislabeled",
468 Spam => "spam",
469 Abuse => "abuse",
470 Infringement => "infringement",
471 Other => "other",
472 });
473
474 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
475 #[serde(rename_all = "lowercase")]
476 pub enum ReportStatus {
477 Open,
478 Resolved,
479 Dismissed,
480 }
481
482 impl_str_enum!(ReportStatus {
483 Open => "open",
484 Resolved => "resolved",
485 Dismissed => "dismissed",
486 });
487
488 // ── Creator Tiers ──
489
490 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
491 #[serde(rename_all = "snake_case")]
492 pub enum CreatorTier {
493 Basic,
494 SmallFiles,
495 BigFiles,
496 Everything,
497 }
498
499 impl_str_enum!(CreatorTier {
500 Basic => "basic",
501 SmallFiles => "small_files",
502 BigFiles => "big_files",
503 Everything => "everything",
504 });
505
506 impl CreatorTier {
507 /// Human-readable label for display.
508 pub fn label(&self) -> &'static str {
509 match self {
510 Self::Basic => "Basic",
511 Self::SmallFiles => "Small Files",
512 Self::BigFiles => "Big Files",
513 Self::Everything => "Everything",
514 }
515 }
516
517 /// Monthly price in cents.
518 pub fn price_cents(&self) -> i32 {
519 match self {
520 Self::Basic => 1600,
521 Self::SmallFiles => 2400,
522 Self::BigFiles => 3600,
523 Self::Everything => 6000,
524 }
525 }
526
527 /// Maximum per-file upload size in bytes.
528 pub fn max_file_bytes(&self) -> i64 {
529 match self {
530 Self::Basic => 10 * 1024 * 1024, // 10 MB
531 Self::SmallFiles => 500 * 1024 * 1024, // 500 MB
532 Self::BigFiles => 20 * 1024 * 1024 * 1024, // 20 GB
533 Self::Everything => 20 * 1024 * 1024 * 1024, // 20 GB
534 }
535 }
536
537 /// Maximum total storage in bytes.
538 pub fn max_storage_bytes(&self) -> i64 {
539 match self {
540 Self::Basic => 50 * 1024 * 1024 * 1024, // 50 GB
541 Self::SmallFiles => 250 * 1024 * 1024 * 1024, // 250 GB
542 Self::BigFiles => 500 * 1024 * 1024 * 1024, // 500 GB
543 Self::Everything => 500 * 1024 * 1024 * 1024, // 500 GB
544 }
545 }
546
547 /// Whether this tier allows non-cover file uploads (audio, downloads, insertions).
548 /// Basic is text-only; covers are always allowed regardless of tier.
549 pub fn allows_file_uploads(&self) -> bool {
550 !matches!(self, Self::Basic)
551 }
552
553 /// Capability strings exposed to external OAuth implementers via `/oauth/userinfo`.
554 ///
555 /// Implementers gate features on these strings rather than tier names so the
556 /// tier lineup can change without breaking callers. Only ship strings backed by
557 /// live behavior; new capabilities are added when they actually launch.
558 pub fn features(&self) -> &'static [&'static str] {
559 match self {
560 Self::Basic => &[],
561 Self::SmallFiles => &["file_uploads"],
562 Self::BigFiles => &["file_uploads", "large_files"],
563 Self::Everything => &["file_uploads", "large_files"],
564 }
565 }
566 }
567
568 // ── AI Tiers ──
569
570 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
571 #[serde(rename_all = "snake_case")]
572 pub enum AiTier {
573 Handmade,
574 Assisted,
575 Generated,
576 }
577
578 impl_str_enum!(AiTier {
579 Handmade => "handmade",
580 Assisted => "assisted",
581 Generated => "generated",
582 });
583
584 impl AiTier {
585 pub fn label(&self) -> &'static str {
586 match self {
587 Self::Handmade => "Handmade",
588 Self::Assisted => "Assisted",
589 Self::Generated => "Generated",
590 }
591 }
592 }
593
594 /// Discover-page filter shape per `about/generative-ai.md` § "How Fans
595 /// Use This". Distinct from `AiTier` because this is a *filter*, not a
596 /// per-item value: `HumanLed` aggregates the Handmade + Assisted tiers.
597 /// `None` on `DiscoverFilters.ai_tier` means "Everything" — no
598 /// restriction.
599 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
600 pub enum AiTierFilter {
601 HandmadeOnly,
602 HumanLed,
603 }
604
605 impl_str_enum!(AiTierFilter {
606 HandmadeOnly => "handmade_only",
607 HumanLed => "human_led",
608 });
609
610 impl AiTierFilter {
611 pub fn label(&self) -> &'static str {
612 match self {
613 Self::HandmadeOnly => "Handmade only",
614 Self::HumanLed => "Human-led",
615 }
616 }
617 }
618
619 // ── Project Features ──
620
621 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
622 #[serde(rename_all = "snake_case")]
623 pub enum ProjectFeature {
624 Audio,
625 Downloads,
626 Text,
627 Blog,
628 Subscriptions,
629 LicenseKeys,
630 SourceCode,
631 CloudSync,
632 }
633
634 impl_str_enum!(ProjectFeature {
635 Audio => "audio",
636 Downloads => "downloads",
637 Text => "text",
638 Blog => "blog",
639 Subscriptions => "subscriptions",
640 LicenseKeys => "license_keys",
641 SourceCode => "source_code",
642 CloudSync => "cloud_sync",
643 });
644
645 impl ProjectFeature {
646 /// Human-readable label for display.
647 pub fn label(&self) -> &'static str {
648 match self {
649 Self::Audio => "Audio",
650 Self::Downloads => "Downloads",
651 Self::Text => "Text",
652 Self::Blog => "Blog",
653 Self::Subscriptions => "Subscriptions",
654 Self::LicenseKeys => "License Keys",
655 Self::SourceCode => "Source Code",
656 Self::CloudSync => "Cloud Sync",
657 }
658 }
659
660 /// One-line description of what this feature enables.
661 pub fn description(&self) -> &'static str {
662 match self {
663 Self::Audio => "Upload and stream audio files. Player with chapters.",
664 Self::Downloads => "Host file downloads with versioned releases.",
665 Self::Text => "Write and publish text content with markdown.",
666 Self::Blog => "Project blog with RSS feed.",
667 Self::Subscriptions => "Monthly subscriber tiers.",
668 Self::LicenseKeys => "Software license management with activation API.",
669 Self::SourceCode => "Git repository with source browser.",
670 Self::CloudSync => "E2E encrypted cloud sync for desktop and mobile apps.",
671 }
672 }
673
674 /// All features as (value, label, description) tuples for form rendering.
675 pub fn all() -> &'static [(&'static str, &'static str, &'static str)] {
676 &[
677 ("audio", "Audio", "Upload and stream audio files. Player with chapters."),
678 ("downloads", "Downloads", "Host file downloads with versioned releases."),
679 ("text", "Text", "Write and publish text content with markdown."),
680 ("blog", "Blog", "Project blog with RSS feed."),
681 ("subscriptions", "Subscriptions", "Monthly subscriber tiers."),
682 ("license_keys", "License Keys", "Software license management with activation API."),
683 ("source_code", "Source Code", "Git repository with source browser."),
684 ("cloud_sync", "Cloud Sync", "E2E encrypted cloud sync for desktop and mobile apps."),
685 ]
686 }
687
688 /// Derive the best-fit project type from a set of features.
689 pub fn derive_project_type(features: &[String]) -> ProjectType {
690 if features.iter().any(|f| f == "audio") {
691 return ProjectType::Music;
692 }
693 if features.iter().any(|f| f == "text") && !features.iter().any(|f| f == "downloads") {
694 return ProjectType::Blog;
695 }
696 if features.iter().any(|f| f == "downloads") {
697 return ProjectType::Software;
698 }
699 ProjectType::General
700 }
701
702 /// Which item types a feature unlocks.
703 pub fn allowed_item_types(&self) -> &'static [ItemType] {
704 match self {
705 Self::Audio => &[ItemType::Audio, ItemType::Sample, ItemType::Preset],
706 Self::Downloads => &[
707 ItemType::Digital,
708 ItemType::Plugin,
709 ItemType::Template,
710 ItemType::Course,
711 ItemType::Image,
712 ItemType::Video,
713 ],
714 Self::Text => &[ItemType::Text],
715 // Non-content features don't gate item types
716 Self::Blog | Self::Subscriptions | Self::LicenseKeys | Self::SourceCode | Self::CloudSync => &[],
717 }
718 }
719
720 /// Compute the set of item types allowed by a project's feature list.
721 /// If no content features are enabled, all types are allowed (permissive default).
722 pub fn allowed_item_type_cards(
723 features: &[String],
724 ) -> Vec<(&'static str, &'static str, &'static str)> {
725 let allowed: std::collections::HashSet<ItemType> = features
726 .iter()
727 .filter_map(|f| f.parse::<ProjectFeature>().ok())
728 .flat_map(|f| f.allowed_item_types().iter().copied())
729 .collect();
730
731 // If no content features enabled, show all types (backwards compat)
732 if allowed.is_empty() {
733 return Self::all_item_type_cards().to_vec();
734 }
735
736 Self::all_item_type_cards()
737 .iter()
738 .filter(|(value, _, _)| {
739 value
740 .parse::<ItemType>()
741 .map(|t| t == ItemType::Bundle || allowed.contains(&t))
742 .unwrap_or(false)
743 })
744 .copied()
745 .collect()
746 }
747
748 /// All item type cards: (value, label, description) tuples for form rendering.
749 pub fn all_item_type_cards() -> &'static [(&'static str, &'static str, &'static str)] {
750 &[
751 ("audio", "Audio", "Podcast, music, sound effects"),
752 ("text", "Text", "Articles, posts, essays, guides"),
753 ("digital", "Digital Download", "Files, archives, documents"),
754 ("video", "Video", "Tutorials, films, recordings"),
755 ("course", "Course", "Multi-part lessons, curricula"),
756 ("plugin", "Plugin", "Software extensions, add-ons"),
757 ("sample", "Sample Pack", "Audio samples, loops, one-shots"),
758 ("preset", "Preset Pack", "Synth presets, effect chains"),
759 ("template", "Template", "Design templates, starter kits"),
760 ("image", "Image", "Photos, artwork, graphics"),
761 ("bundle", "Bundle", "Collection of other items"),
762 ]
763 }
764
765 /// Item type cards filtered to one per distinct wizard behavior group.
766 ///
767 /// The wizard only needs a type selector when the allowed types produce
768 /// different content-step UIs (text editor vs audio upload vs file upload).
769 /// Returns one card per group, using the first allowed type as the value.
770 /// If all types share one group, returns a single card (caller should skip
771 /// the type step entirely).
772 pub fn wizard_type_cards(
773 features: &[String],
774 ) -> Vec<(&'static str, &'static str, &'static str)> {
775 let allowed = Self::allowed_item_type_cards(features);
776 let mut seen_groups = std::collections::HashSet::new();
777 let mut cards = Vec::new();
778
779 for (value, _, _) in &allowed {
780 let Ok(item_type) = value.parse::<ItemType>() else {
781 continue;
782 };
783 let group = item_type.wizard_group();
784 if seen_groups.insert(group) {
785 let (label, desc) = match group {
786 "text" => ("Text", "Write in the editor"),
787 "audio" => ("Audio", "Upload audio files"),
788 "video" => ("Video", "Upload video files"),
789 "bundle" => ("Bundle", "Collection of other items"),
790 _ => ("File", "Upload any file"),
791 };
792 cards.push((*value, label, desc));
793 }
794 }
795
796 cards
797 }
798 }
799
800 // ── Projects ──
801
802 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
803 #[serde(rename_all = "lowercase")]
804 pub enum ProjectType {
805 Blog,
806 Book,
807 Podcast,
808 Course,
809 Music,
810 Software,
811 Art,
812 Writing,
813 #[default]
814 General,
815 }
816
817 impl_str_enum!(ProjectType {
818 Blog => "blog",
819 Book => "book",
820 Podcast => "podcast",
821 Course => "course",
822 Music => "music",
823 Software => "software",
824 Art => "art",
825 Writing => "writing",
826 General => "general",
827 });
828
829 impl ProjectType {
830 /// Human-readable label for display.
831 pub fn label(&self) -> &'static str {
832 match self {
833 Self::Blog => "Blog",
834 Self::Book => "Book",
835 Self::Podcast => "Podcast",
836 Self::Course => "Course",
837 Self::Music => "Music",
838 Self::Software => "Software",
839 Self::Art => "Art",
840 Self::Writing => "Writing",
841 Self::General => "General",
842 }
843 }
844
845 /// All valid project types as (value, label) pairs for form rendering.
846 pub fn all() -> &'static [(&'static str, &'static str)] {
847 &[
848 ("blog", "Blog"),
849 ("book", "Book"),
850 ("podcast", "Podcast"),
851 ("course", "Course"),
852 ("music", "Music"),
853 ("software", "Software"),
854 ("art", "Art"),
855 ("writing", "Writing"),
856 ("general", "General"),
857 ]
858 }
859 }
860
861 // ── Build Pipeline ──
862
863 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
864 #[serde(rename_all = "snake_case")]
865 pub enum BuildStatus {
866 Pending,
867 Running,
868 Succeeded,
869 Failed,
870 Cancelled,
871 }
872
873 impl_str_enum!(BuildStatus {
874 Pending => "pending",
875 Running => "running",
876 Succeeded => "succeeded",
877 Failed => "failed",
878 Cancelled => "cancelled",
879 });
880
881 // ── Project Pricing ──
882
883 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
884 #[serde(rename_all = "snake_case")]
885 pub enum PricingKind {
886 #[default]
887 Free,
888 BuyOnce,
889 Pwyw,
890 Subscription,
891 }
892
893 impl_str_enum!(PricingKind {
894 Free => "free",
895 BuyOnce => "buy_once",
896 Pwyw => "pwyw",
897 Subscription => "subscription",
898 });
899
900 // ── Mailing Lists ──
901
902 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
903 #[serde(rename_all = "lowercase")]
904 pub enum MailingListType {
905 Content,
906 Devlog,
907 Patches,
908 }
909
910 impl_str_enum!(MailingListType {
911 Content => "content",
912 Devlog => "devlog",
913 Patches => "patches",
914 });
915
916 // ── Import System ──
917
918 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
919 #[serde(rename_all = "snake_case")]
920 pub enum ImportSource {
921 GenericCsv,
922 Substack,
923 Ghost,
924 Gumroad,
925 Bandcamp,
926 LemonSqueezy,
927 Patreon,
928 }
929
930 impl_str_enum!(ImportSource {
931 GenericCsv => "generic_csv",
932 Substack => "substack",
933 Ghost => "ghost",
934 Gumroad => "gumroad",
935 Bandcamp => "bandcamp",
936 LemonSqueezy => "lemon_squeezy",
937 Patreon => "patreon",
938 });
939
940 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
941 #[serde(rename_all = "lowercase")]
942 pub enum ImportJobStatus {
943 Pending,
944 Processing,
945 Completed,
946 Failed,
947 }
948
949 impl_str_enum!(ImportJobStatus {
950 Pending => "pending",
951 Processing => "processing",
952 Completed => "completed",
953 Failed => "failed",
954 });
955
956 // -- Moderation action types --------------------------------------------------
957
958 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
959 pub enum ModerationActionType {
960 Warning,
961 Suspension,
962 Termination,
963 ContentRemoval,
964 }
965
966 impl_str_enum!(ModerationActionType {
967 Warning => "warning",
968 Suspension => "suspension",
969 Termination => "termination",
970 ContentRemoval => "content_removal",
971 });
972
973 // -- Checkout types (Stripe metadata) -----------------------------------------
974
975 /// Discriminator for checkout session types stored in Stripe metadata.
976 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
977 pub enum CheckoutType {
978 Guest,
979 Subscription,
980 Tip,
981 FanPlus,
982 CreatorTier,
983 Cart,
984 SynckitAppSub,
985 }
986
987 impl_str_enum!(CheckoutType {
988 Guest => "guest",
989 Subscription => "subscription",
990 Tip => "tip",
991 FanPlus => "fan_plus",
992 CreatorTier => "creator_tier",
993 Cart => "cart",
994 SynckitAppSub => "synckit_app_sub",
995 });
996
997 impl ModerationActionType {
998 pub fn label(&self) -> &'static str {
999 match self {
1000 Self::Warning => "Warning",
1001 Self::Suspension => "Suspension",
1002 Self::Termination => "Termination",
1003 Self::ContentRemoval => "Content Removal",
1004 }
1005 }
1006 }
1007
1008 #[cfg(test)]
1009 mod tests {
1010 use super::*;
1011
1012 #[test]
1013 fn discount_type_round_trip() {
1014 assert_eq!(DiscountType::Percentage.to_string(), "percentage");
1015 assert_eq!("fixed".parse::<DiscountType>().unwrap(), DiscountType::Fixed);
1016 assert!("bogus".parse::<DiscountType>().is_err());
1017 }
1018
1019 #[test]
1020 fn waitlist_status_round_trip() {
1021 assert_eq!(WaitlistStatus::Pending.to_string(), "pending");
1022 assert_eq!("approved".parse::<WaitlistStatus>().unwrap(), WaitlistStatus::Approved);
1023 }
1024
1025 #[test]
1026 fn selection_method_round_trip() {
1027 assert_eq!(SelectionMethod::HandPicked.to_string(), "hand_picked");
1028 assert_eq!("lottery".parse::<SelectionMethod>().unwrap(), SelectionMethod::Lottery);
1029 assert_eq!(SelectionMethod::Invited.to_string(), "invited");
1030 assert_eq!("invited".parse::<SelectionMethod>().unwrap(), SelectionMethod::Invited);
1031 }
1032
1033 #[test]
1034 fn transaction_status_round_trip() {
1035 assert_eq!(TransactionStatus::Completed.to_string(), "completed");
1036 assert_eq!("refunded".parse::<TransactionStatus>().unwrap(), TransactionStatus::Refunded);
1037 }
1038
1039 #[test]
1040 fn follow_target_type_round_trip() {
1041 assert_eq!(FollowTargetType::User.to_string(), "user");
1042 assert_eq!("tag".parse::<FollowTargetType>().unwrap(), FollowTargetType::Tag);
1043 }
1044
1045 #[test]
1046 fn subscription_status_round_trip() {
1047 assert_eq!(SubscriptionStatus::PastDue.to_string(), "past_due");
1048 assert_eq!("canceled".parse::<SubscriptionStatus>().unwrap(), SubscriptionStatus::Canceled);
1049 assert_eq!(SubscriptionStatus::Trialing.to_string(), "trialing");
1050 assert_eq!("trialing".parse::<SubscriptionStatus>().unwrap(), SubscriptionStatus::Trialing);
1051 assert_eq!(SubscriptionStatus::Incomplete.to_string(), "incomplete");
1052 assert_eq!("incomplete".parse::<SubscriptionStatus>().unwrap(), SubscriptionStatus::Incomplete);
1053 assert_eq!(SubscriptionStatus::IncompleteExpired.to_string(), "incomplete_expired");
1054 assert_eq!("incomplete_expired".parse::<SubscriptionStatus>().unwrap(), SubscriptionStatus::IncompleteExpired);
1055 }
1056
1057 #[test]
1058 fn sync_operation_round_trip() {
1059 assert_eq!(SyncOperation::Insert.to_string(), "INSERT");
1060 assert_eq!("DELETE".parse::<SyncOperation>().unwrap(), SyncOperation::Delete);
1061 }
1062
1063 #[test]
1064 fn sync_platform_round_trip() {
1065 assert_eq!(SyncPlatform::Macos.to_string(), "macos");
1066 assert_eq!("web".parse::<SyncPlatform>().unwrap(), SyncPlatform::Web);
1067 }
1068
1069 #[test]
1070 fn item_type_round_trip() {
1071 assert_eq!(ItemType::Audio.to_string(), "audio");
1072 assert_eq!("plugin".parse::<ItemType>().unwrap(), ItemType::Plugin);
1073 assert_eq!(ItemType::Bundle.to_string(), "bundle");
1074 assert_eq!("bundle".parse::<ItemType>().unwrap(), ItemType::Bundle);
1075 }
1076
1077 #[test]
1078 fn insertion_position_round_trip() {
1079 assert_eq!(InsertionPosition::PreRoll.to_string(), "pre_roll");
1080 assert_eq!("mid_roll".parse::<InsertionPosition>().unwrap(), InsertionPosition::MidRoll);
1081 assert_eq!("post_roll".parse::<InsertionPosition>().unwrap(), InsertionPosition::PostRoll);
1082 assert!("invalid".parse::<InsertionPosition>().is_err());
1083 }
1084
1085 #[test]
1086 fn item_type_label() {
1087 assert_eq!(ItemType::Audio.label(), "Audio");
1088 assert_eq!(ItemType::Plugin.label(), "Plugin");
1089 assert_eq!(ItemType::Template.label(), "Template");
1090 }
1091
1092 #[test]
1093 fn appeal_decision_round_trip() {
1094 assert_eq!(AppealDecision::Approved.to_string(), "approved");
1095 assert_eq!("denied".parse::<AppealDecision>().unwrap(), AppealDecision::Denied);
1096 assert!("bogus".parse::<AppealDecision>().is_err());
1097 }
1098
1099 #[test]
1100 fn discover_sort_round_trip() {
1101 assert_eq!(DiscoverSort::Newest.to_string(), "newest");
1102 assert_eq!("most_sold".parse::<DiscoverSort>().unwrap(), DiscoverSort::MostSold);
1103 assert_eq!("price_asc".parse::<DiscoverSort>().unwrap(), DiscoverSort::PriceAsc);
1104 assert_eq!("price_desc".parse::<DiscoverSort>().unwrap(), DiscoverSort::PriceDesc);
1105 assert!("invalid".parse::<DiscoverSort>().is_err());
1106 }
1107
1108 #[test]
1109 fn file_scan_status_round_trip() {
1110 assert_eq!(FileScanStatus::Clean.to_string(), "clean");
1111 assert_eq!(FileScanStatus::Pending.to_string(), "pending");
1112 assert_eq!(FileScanStatus::Scanning.to_string(), "scanning");
1113 assert_eq!("pending".parse::<FileScanStatus>().unwrap(), FileScanStatus::Pending);
1114 assert_eq!("scanning".parse::<FileScanStatus>().unwrap(), FileScanStatus::Scanning);
1115 assert_eq!("held_for_review".parse::<FileScanStatus>().unwrap(), FileScanStatus::HeldForReview);
1116 assert_eq!(FileScanStatus::HeldForReview.to_string(), "held_for_review");
1117 assert_eq!("quarantined".parse::<FileScanStatus>().unwrap(), FileScanStatus::Quarantined);
1118 assert!("bogus".parse::<FileScanStatus>().is_err());
1119 }
1120
1121 #[test]
1122 fn code_purpose_round_trip() {
1123 assert_eq!(CodePurpose::Discount.to_string(), "discount");
1124 assert_eq!("free_access".parse::<CodePurpose>().unwrap(), CodePurpose::FreeAccess);
1125 assert_eq!("free_trial".parse::<CodePurpose>().unwrap(), CodePurpose::FreeTrial);
1126 assert!("bogus".parse::<CodePurpose>().is_err());
1127 }
1128
1129 #[test]
1130 fn issue_status_round_trip() {
1131 assert_eq!(IssueStatus::Open.to_string(), "open");
1132 assert_eq!("closed".parse::<IssueStatus>().unwrap(), IssueStatus::Closed);
1133 assert!("bogus".parse::<IssueStatus>().is_err());
1134 }
1135
1136 #[test]
1137 fn report_target_type_round_trip() {
1138 assert_eq!(ReportTargetType::Project.to_string(), "project");
1139 assert_eq!("item".parse::<ReportTargetType>().unwrap(), ReportTargetType::Item);
1140 assert!("bogus".parse::<ReportTargetType>().is_err());
1141 }
1142
1143 #[test]
1144 fn report_type_round_trip() {
1145 assert_eq!(ReportType::Mislabeled.to_string(), "mislabeled");
1146 assert_eq!("spam".parse::<ReportType>().unwrap(), ReportType::Spam);
1147 assert_eq!("abuse".parse::<ReportType>().unwrap(), ReportType::Abuse);
1148 assert_eq!("infringement".parse::<ReportType>().unwrap(), ReportType::Infringement);
1149 assert_eq!("other".parse::<ReportType>().unwrap(), ReportType::Other);
1150 assert!("bogus".parse::<ReportType>().is_err());
1151 }
1152
1153 #[test]
1154 fn report_status_round_trip() {
1155 assert_eq!(ReportStatus::Open.to_string(), "open");
1156 assert_eq!("resolved".parse::<ReportStatus>().unwrap(), ReportStatus::Resolved);
1157 assert_eq!("dismissed".parse::<ReportStatus>().unwrap(), ReportStatus::Dismissed);
1158 assert!("bogus".parse::<ReportStatus>().is_err());
1159 }
1160
1161 #[test]
1162 fn creator_tier_round_trip() {
1163 assert_eq!(CreatorTier::Basic.to_string(), "basic");
1164 assert_eq!("small_files".parse::<CreatorTier>().unwrap(), CreatorTier::SmallFiles);
1165 assert_eq!("big_files".parse::<CreatorTier>().unwrap(), CreatorTier::BigFiles);
1166 assert_eq!("everything".parse::<CreatorTier>().unwrap(), CreatorTier::Everything);
1167 assert!("bogus".parse::<CreatorTier>().is_err());
1168 }
1169
1170 #[test]
1171 fn creator_tier_label_and_price() {
1172 assert_eq!(CreatorTier::Basic.label(), "Basic");
1173 assert_eq!(CreatorTier::SmallFiles.label(), "Small Files");
1174 assert_eq!(CreatorTier::Basic.price_cents(), 1600);
1175 assert_eq!(CreatorTier::Everything.price_cents(), 6000);
1176 }
1177
1178 #[test]
1179 fn creator_tier_file_limits() {
1180 assert_eq!(CreatorTier::Basic.max_file_bytes(), 10 * 1024 * 1024);
1181 assert_eq!(CreatorTier::SmallFiles.max_file_bytes(), 500 * 1024 * 1024);
1182 assert_eq!(CreatorTier::BigFiles.max_file_bytes(), 20 * 1024 * 1024 * 1024);
1183 assert_eq!(CreatorTier::Everything.max_file_bytes(), 20 * 1024 * 1024 * 1024);
1184 }
1185
1186 #[test]
1187 fn creator_tier_storage_limits() {
1188 assert_eq!(CreatorTier::Basic.max_storage_bytes(), 50 * 1024 * 1024 * 1024);
1189 assert_eq!(CreatorTier::SmallFiles.max_storage_bytes(), 250 * 1024 * 1024 * 1024);
1190 assert_eq!(CreatorTier::BigFiles.max_storage_bytes(), 500 * 1024 * 1024 * 1024);
1191 assert_eq!(CreatorTier::Everything.max_storage_bytes(), 500 * 1024 * 1024 * 1024);
1192 }
1193
1194 #[test]
1195 fn creator_tier_allows_file_uploads() {
1196 assert!(!CreatorTier::Basic.allows_file_uploads());
1197 assert!(CreatorTier::SmallFiles.allows_file_uploads());
1198 assert!(CreatorTier::BigFiles.allows_file_uploads());
1199 assert!(CreatorTier::Everything.allows_file_uploads());
1200 }
1201
1202 #[test]
1203 fn creator_tier_features_track_live_capabilities() {
1204 assert!(CreatorTier::Basic.features().is_empty());
1205 assert_eq!(CreatorTier::SmallFiles.features(), &["file_uploads"]);
1206 assert_eq!(CreatorTier::BigFiles.features(), &["file_uploads", "large_files"]);
1207 assert_eq!(CreatorTier::Everything.features(), &["file_uploads", "large_files"]);
1208 }
1209
1210 #[test]
1211 fn project_feature_round_trip() {
1212 assert_eq!(ProjectFeature::Audio.to_string(), "audio");
1213 assert_eq!("downloads".parse::<ProjectFeature>().unwrap(), ProjectFeature::Downloads);
1214 assert_eq!("license_keys".parse::<ProjectFeature>().unwrap(), ProjectFeature::LicenseKeys);
1215 assert_eq!("source_code".parse::<ProjectFeature>().unwrap(), ProjectFeature::SourceCode);
1216 assert!("bogus".parse::<ProjectFeature>().is_err());
1217 }
1218
1219 #[test]
1220 fn project_feature_label_and_description() {
1221 assert_eq!(ProjectFeature::Audio.label(), "Audio");
1222 assert_eq!(ProjectFeature::LicenseKeys.label(), "License Keys");
1223 assert!(!ProjectFeature::Audio.description().is_empty());
1224 }
1225
1226 #[test]
1227 fn project_feature_all() {
1228 let all = ProjectFeature::all();
1229 assert_eq!(all.len(), 8);
1230 assert_eq!(all[0].0, "audio");
1231 assert_eq!(all[7].0, "cloud_sync");
1232 }
1233
1234 #[test]
1235 fn project_feature_allowed_item_types_audio() {
1236 let types = ProjectFeature::Audio.allowed_item_types();
1237 assert!(types.contains(&ItemType::Audio));
1238 assert!(types.contains(&ItemType::Sample));
1239 assert!(types.contains(&ItemType::Preset));
1240 assert!(!types.contains(&ItemType::Text));
1241 }
1242
1243 #[test]
1244 fn project_feature_allowed_item_types_downloads() {
1245 let types = ProjectFeature::Downloads.allowed_item_types();
1246 assert!(types.contains(&ItemType::Digital));
1247 assert!(types.contains(&ItemType::Plugin));
1248 assert!(types.contains(&ItemType::Video));
1249 assert!(!types.contains(&ItemType::Audio));
1250 }
1251
1252 #[test]
1253 fn project_feature_allowed_item_types_text() {
1254 let types = ProjectFeature::Text.allowed_item_types();
1255 assert!(types.contains(&ItemType::Text));
1256 assert_eq!(types.len(), 1);
1257 }
1258
1259 #[test]
1260 fn project_feature_allowed_item_types_non_content() {
1261 assert!(ProjectFeature::Blog.allowed_item_types().is_empty());
1262 assert!(ProjectFeature::Subscriptions.allowed_item_types().is_empty());
1263 assert!(ProjectFeature::LicenseKeys.allowed_item_types().is_empty());
1264 assert!(ProjectFeature::SourceCode.allowed_item_types().is_empty());
1265 assert!(ProjectFeature::CloudSync.allowed_item_types().is_empty());
1266 }
1267
1268 #[test]
1269 fn project_feature_allowed_cards_filtered() {
1270 let cards = ProjectFeature::allowed_item_type_cards(&["audio".into()]);
1271 let values: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
1272 assert!(values.contains(&"audio"));
1273 assert!(values.contains(&"sample"));
1274 assert!(values.contains(&"preset"));
1275 assert!(values.contains(&"bundle")); // Bundle always included
1276 assert!(!values.contains(&"text"));
1277 assert!(!values.contains(&"digital"));
1278 }
1279
1280 #[test]
1281 fn project_feature_allowed_cards_combined() {
1282 let cards = ProjectFeature::allowed_item_type_cards(&["audio".into(), "text".into()]);
1283 let values: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
1284 assert!(values.contains(&"audio"));
1285 assert!(values.contains(&"text"));
1286 assert!(values.contains(&"bundle")); // Bundle always included
1287 assert!(!values.contains(&"digital"));
1288 }
1289
1290 #[test]
1291 fn project_feature_allowed_cards_empty_features_shows_all() {
1292 let cards = ProjectFeature::allowed_item_type_cards(&[]);
1293 assert_eq!(cards.len(), 11); // 10 content types + bundle
1294 }
1295
1296 #[test]
1297 fn project_feature_allowed_cards_non_content_features_shows_all() {
1298 let cards = ProjectFeature::allowed_item_type_cards(&["blog".into(), "subscriptions".into()]);
1299 // Blog and subscriptions don't gate item types, so all should be shown
1300 assert_eq!(cards.len(), 11); // 10 content types + bundle
1301 }
1302
1303 #[test]
1304 fn project_feature_derive_type() {
1305 assert_eq!(
1306 ProjectFeature::derive_project_type(&["audio".into(), "blog".into()]),
1307 ProjectType::Music,
1308 );
1309 assert_eq!(
1310 ProjectFeature::derive_project_type(&["text".into()]),
1311 ProjectType::Blog,
1312 );
1313 assert_eq!(
1314 ProjectFeature::derive_project_type(&["downloads".into(), "text".into()]),
1315 ProjectType::Software,
1316 );
1317 assert_eq!(
1318 ProjectFeature::derive_project_type(&["subscriptions".into()]),
1319 ProjectType::General,
1320 );
1321 }
1322
1323 #[test]
1324 fn project_type_round_trip() {
1325 assert_eq!(ProjectType::Blog.to_string(), "blog");
1326 assert_eq!("software".parse::<ProjectType>().unwrap(), ProjectType::Software);
1327 assert_eq!("general".parse::<ProjectType>().unwrap(), ProjectType::General);
1328 assert_eq!(ProjectType::default(), ProjectType::General);
1329 assert!("bogus".parse::<ProjectType>().is_err());
1330 }
1331
1332 #[test]
1333 fn project_type_label() {
1334 assert_eq!(ProjectType::Blog.label(), "Blog");
1335 assert_eq!(ProjectType::Software.label(), "Software");
1336 assert_eq!(ProjectType::General.label(), "General");
1337 }
1338
1339 #[test]
1340 fn project_type_all() {
1341 let all = ProjectType::all();
1342 assert_eq!(all.len(), 9);
1343 assert_eq!(all[0], ("blog", "Blog"));
1344 assert_eq!(all[8], ("general", "General"));
1345 }
1346
1347 #[test]
1348 fn build_status_round_trip() {
1349 assert_eq!(BuildStatus::Pending.to_string(), "pending");
1350 assert_eq!("running".parse::<BuildStatus>().unwrap(), BuildStatus::Running);
1351 assert_eq!("succeeded".parse::<BuildStatus>().unwrap(), BuildStatus::Succeeded);
1352 assert_eq!("failed".parse::<BuildStatus>().unwrap(), BuildStatus::Failed);
1353 assert_eq!("cancelled".parse::<BuildStatus>().unwrap(), BuildStatus::Cancelled);
1354 assert!("bogus".parse::<BuildStatus>().is_err());
1355 }
1356
1357 #[test]
1358 fn serde_json_round_trip() {
1359 let dt = DiscountType::Percentage;
1360 let json = serde_json::to_string(&dt).unwrap();
1361 assert_eq!(json, "\"percentage\"");
1362 let back: DiscountType = serde_json::from_str(&json).unwrap();
1363 assert_eq!(back, dt);
1364 }
1365
1366 #[test]
1367 fn pricing_kind_round_trip() {
1368 assert_eq!(PricingKind::Free.to_string(), "free");
1369 assert_eq!("buy_once".parse::<PricingKind>().unwrap(), PricingKind::BuyOnce);
1370 assert_eq!("pwyw".parse::<PricingKind>().unwrap(), PricingKind::Pwyw);
1371 assert_eq!("subscription".parse::<PricingKind>().unwrap(), PricingKind::Subscription);
1372 assert_eq!(PricingKind::default(), PricingKind::Free);
1373 assert!("bogus".parse::<PricingKind>().is_err());
1374 }
1375
1376 #[test]
1377 fn mailing_list_type_round_trip() {
1378 assert_eq!(MailingListType::Content.to_string(), "content");
1379 assert_eq!("devlog".parse::<MailingListType>().unwrap(), MailingListType::Devlog);
1380 assert_eq!("patches".parse::<MailingListType>().unwrap(), MailingListType::Patches);
1381 assert!("bogus".parse::<MailingListType>().is_err());
1382 }
1383
1384 #[test]
1385 fn serde_json_subscription_status() {
1386 let s = SubscriptionStatus::PastDue;
1387 let json = serde_json::to_string(&s).unwrap();
1388 assert_eq!(json, "\"past_due\"");
1389 let back: SubscriptionStatus = serde_json::from_str(&json).unwrap();
1390 assert_eq!(back, s);
1391
1392 let t = SubscriptionStatus::Trialing;
1393 let json = serde_json::to_string(&t).unwrap();
1394 assert_eq!(json, "\"trialing\"");
1395 let back: SubscriptionStatus = serde_json::from_str(&json).unwrap();
1396 assert_eq!(back, t);
1397 }
1398
1399 // ── ItemType::wizard_group ──
1400
1401 #[test]
1402 fn wizard_group_text() {
1403 assert_eq!(ItemType::Text.wizard_group(), "text");
1404 }
1405
1406 #[test]
1407 fn wizard_group_audio() {
1408 assert_eq!(ItemType::Audio.wizard_group(), "audio");
1409 }
1410
1411 #[test]
1412 fn wizard_group_video() {
1413 assert_eq!(ItemType::Video.wizard_group(), "video");
1414 }
1415
1416 #[test]
1417 fn wizard_group_file_types() {
1418 for t in [
1419 ItemType::Digital,
1420 ItemType::Course,
1421 ItemType::Plugin,
1422 ItemType::Sample,
1423 ItemType::Preset,
1424 ItemType::Template,
1425 ItemType::Image,
1426 ] {
1427 assert_eq!(t.wizard_group(), "file", "{t:?} should be in file group");
1428 }
1429 }
1430
1431 #[test]
1432 fn wizard_group_bundle() {
1433 assert_eq!(ItemType::Bundle.wizard_group(), "bundle");
1434 }
1435
1436 // ── ProjectFeature::wizard_type_cards ──
1437
1438 #[test]
1439 fn wizard_cards_text_only_two_groups() {
1440 // Text + bundle (bundle always included)
1441 let cards = ProjectFeature::wizard_type_cards(&["text".into()]);
1442 assert_eq!(cards.len(), 2);
1443 let groups: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
1444 assert!(groups.contains(&"text"));
1445 assert!(groups.contains(&"bundle"));
1446 }
1447
1448 #[test]
1449 fn wizard_cards_downloads_three_groups() {
1450 // Download types split into "file" + "video" groups + bundle → 3 cards
1451 let cards = ProjectFeature::wizard_type_cards(&["downloads".into()]);
1452 assert_eq!(cards.len(), 3);
1453 let groups: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
1454 assert!(groups.contains(&"digital")); // first type in file group
1455 assert!(groups.contains(&"video")); // video group
1456 assert!(groups.contains(&"bundle"));
1457 }
1458
1459 #[test]
1460 fn wizard_cards_audio_feature_three_groups() {
1461 // Audio feature allows audio (audio group) + sample, preset (file group) + bundle
1462 let cards = ProjectFeature::wizard_type_cards(&["audio".into()]);
1463 assert_eq!(cards.len(), 3);
1464 let groups: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
1465 assert!(groups.contains(&"audio"));
1466 assert!(groups.contains(&"sample")); // first file-group type
1467 assert!(groups.contains(&"bundle"));
1468 }
1469
1470 #[test]
1471 fn wizard_cards_text_and_audio_four_groups() {
1472 let cards =
1473 ProjectFeature::wizard_type_cards(&["text".into(), "audio".into()]);
1474 assert_eq!(cards.len(), 4); // text, audio, file, bundle
1475 }
1476
1477 #[test]
1478 fn wizard_cards_empty_features_all_five_groups() {
1479 // No content features → all types → 5 wizard groups (text, audio, video, file, bundle)
1480 let cards = ProjectFeature::wizard_type_cards(&[]);
1481 assert_eq!(cards.len(), 5);
1482 }
1483
1484 #[test]
1485 fn ai_tier_round_trip() {
1486 assert_eq!(AiTier::Handmade.to_string(), "handmade");
1487 assert_eq!("assisted".parse::<AiTier>().unwrap(), AiTier::Assisted);
1488 assert_eq!("generated".parse::<AiTier>().unwrap(), AiTier::Generated);
1489 assert!("bogus".parse::<AiTier>().is_err());
1490 }
1491
1492 #[test]
1493 fn ai_tier_label() {
1494 assert_eq!(AiTier::Handmade.label(), "Handmade");
1495 assert_eq!(AiTier::Assisted.label(), "Assisted");
1496 assert_eq!(AiTier::Generated.label(), "Generated");
1497 }
1498
1499 #[test]
1500 fn import_source_round_trip() {
1501 assert_eq!(ImportSource::GenericCsv.to_string(), "generic_csv");
1502 assert_eq!("substack".parse::<ImportSource>().unwrap(), ImportSource::Substack);
1503 assert_eq!("ghost".parse::<ImportSource>().unwrap(), ImportSource::Ghost);
1504 assert_eq!("gumroad".parse::<ImportSource>().unwrap(), ImportSource::Gumroad);
1505 assert_eq!("bandcamp".parse::<ImportSource>().unwrap(), ImportSource::Bandcamp);
1506 assert_eq!("lemon_squeezy".parse::<ImportSource>().unwrap(), ImportSource::LemonSqueezy);
1507 assert_eq!("patreon".parse::<ImportSource>().unwrap(), ImportSource::Patreon);
1508 assert!("bogus".parse::<ImportSource>().is_err());
1509 }
1510
1511 #[test]
1512 fn import_job_status_round_trip() {
1513 assert_eq!(ImportJobStatus::Pending.to_string(), "pending");
1514 assert_eq!("processing".parse::<ImportJobStatus>().unwrap(), ImportJobStatus::Processing);
1515 assert_eq!("completed".parse::<ImportJobStatus>().unwrap(), ImportJobStatus::Completed);
1516 assert_eq!("failed".parse::<ImportJobStatus>().unwrap(), ImportJobStatus::Failed);
1517 assert!("bogus".parse::<ImportJobStatus>().is_err());
1518 }
1519
1520 #[test]
1521 fn checkout_type_round_trip() {
1522 assert_eq!(CheckoutType::Guest.to_string(), "guest");
1523 assert_eq!(CheckoutType::Subscription.to_string(), "subscription");
1524 assert_eq!(CheckoutType::Tip.to_string(), "tip");
1525 assert_eq!(CheckoutType::FanPlus.to_string(), "fan_plus");
1526 assert_eq!(CheckoutType::CreatorTier.to_string(), "creator_tier");
1527 assert_eq!("guest".parse::<CheckoutType>().unwrap(), CheckoutType::Guest);
1528 assert_eq!("fan_plus".parse::<CheckoutType>().unwrap(), CheckoutType::FanPlus);
1529 assert!("bogus".parse::<CheckoutType>().is_err());
1530 }
1531
1532 #[test]
1533 fn moderation_action_type_round_trip() {
1534 assert_eq!(ModerationActionType::Warning.to_string(), "warning");
1535 assert_eq!(ModerationActionType::Suspension.to_string(), "suspension");
1536 assert_eq!(ModerationActionType::Termination.to_string(), "termination");
1537 assert_eq!(ModerationActionType::ContentRemoval.to_string(), "content_removal");
1538 assert_eq!("warning".parse::<ModerationActionType>().unwrap(), ModerationActionType::Warning);
1539 assert_eq!("content_removal".parse::<ModerationActionType>().unwrap(), ModerationActionType::ContentRemoval);
1540 assert!("bogus".parse::<ModerationActionType>().is_err());
1541 }
1542 }
1543