Skip to main content

max / makenotwork

48.8 KB · 1518 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 // ── Project Features ──
595
596 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
597 #[serde(rename_all = "snake_case")]
598 pub enum ProjectFeature {
599 Audio,
600 Downloads,
601 Text,
602 Blog,
603 Subscriptions,
604 LicenseKeys,
605 SourceCode,
606 CloudSync,
607 }
608
609 impl_str_enum!(ProjectFeature {
610 Audio => "audio",
611 Downloads => "downloads",
612 Text => "text",
613 Blog => "blog",
614 Subscriptions => "subscriptions",
615 LicenseKeys => "license_keys",
616 SourceCode => "source_code",
617 CloudSync => "cloud_sync",
618 });
619
620 impl ProjectFeature {
621 /// Human-readable label for display.
622 pub fn label(&self) -> &'static str {
623 match self {
624 Self::Audio => "Audio",
625 Self::Downloads => "Downloads",
626 Self::Text => "Text",
627 Self::Blog => "Blog",
628 Self::Subscriptions => "Subscriptions",
629 Self::LicenseKeys => "License Keys",
630 Self::SourceCode => "Source Code",
631 Self::CloudSync => "Cloud Sync",
632 }
633 }
634
635 /// One-line description of what this feature enables.
636 pub fn description(&self) -> &'static str {
637 match self {
638 Self::Audio => "Upload and stream audio files. Player with chapters.",
639 Self::Downloads => "Host file downloads with versioned releases.",
640 Self::Text => "Write and publish text content with markdown.",
641 Self::Blog => "Project blog with RSS feed.",
642 Self::Subscriptions => "Monthly subscriber tiers.",
643 Self::LicenseKeys => "Software license management with activation API.",
644 Self::SourceCode => "Git repository with source browser.",
645 Self::CloudSync => "E2E encrypted cloud sync for desktop and mobile apps.",
646 }
647 }
648
649 /// All features as (value, label, description) tuples for form rendering.
650 pub fn all() -> &'static [(&'static str, &'static str, &'static str)] {
651 &[
652 ("audio", "Audio", "Upload and stream audio files. Player with chapters."),
653 ("downloads", "Downloads", "Host file downloads with versioned releases."),
654 ("text", "Text", "Write and publish text content with markdown."),
655 ("blog", "Blog", "Project blog with RSS feed."),
656 ("subscriptions", "Subscriptions", "Monthly subscriber tiers."),
657 ("license_keys", "License Keys", "Software license management with activation API."),
658 ("source_code", "Source Code", "Git repository with source browser."),
659 ("cloud_sync", "Cloud Sync", "E2E encrypted cloud sync for desktop and mobile apps."),
660 ]
661 }
662
663 /// Derive the best-fit project type from a set of features.
664 pub fn derive_project_type(features: &[String]) -> ProjectType {
665 if features.iter().any(|f| f == "audio") {
666 return ProjectType::Music;
667 }
668 if features.iter().any(|f| f == "text") && !features.iter().any(|f| f == "downloads") {
669 return ProjectType::Blog;
670 }
671 if features.iter().any(|f| f == "downloads") {
672 return ProjectType::Software;
673 }
674 ProjectType::General
675 }
676
677 /// Which item types a feature unlocks.
678 pub fn allowed_item_types(&self) -> &'static [ItemType] {
679 match self {
680 Self::Audio => &[ItemType::Audio, ItemType::Sample, ItemType::Preset],
681 Self::Downloads => &[
682 ItemType::Digital,
683 ItemType::Plugin,
684 ItemType::Template,
685 ItemType::Course,
686 ItemType::Image,
687 ItemType::Video,
688 ],
689 Self::Text => &[ItemType::Text],
690 // Non-content features don't gate item types
691 Self::Blog | Self::Subscriptions | Self::LicenseKeys | Self::SourceCode | Self::CloudSync => &[],
692 }
693 }
694
695 /// Compute the set of item types allowed by a project's feature list.
696 /// If no content features are enabled, all types are allowed (permissive default).
697 pub fn allowed_item_type_cards(
698 features: &[String],
699 ) -> Vec<(&'static str, &'static str, &'static str)> {
700 let allowed: std::collections::HashSet<ItemType> = features
701 .iter()
702 .filter_map(|f| f.parse::<ProjectFeature>().ok())
703 .flat_map(|f| f.allowed_item_types().iter().copied())
704 .collect();
705
706 // If no content features enabled, show all types (backwards compat)
707 if allowed.is_empty() {
708 return Self::all_item_type_cards().to_vec();
709 }
710
711 Self::all_item_type_cards()
712 .iter()
713 .filter(|(value, _, _)| {
714 value
715 .parse::<ItemType>()
716 .map(|t| t == ItemType::Bundle || allowed.contains(&t))
717 .unwrap_or(false)
718 })
719 .copied()
720 .collect()
721 }
722
723 /// All item type cards: (value, label, description) tuples for form rendering.
724 pub fn all_item_type_cards() -> &'static [(&'static str, &'static str, &'static str)] {
725 &[
726 ("audio", "Audio", "Podcast, music, sound effects"),
727 ("text", "Text", "Articles, posts, essays, guides"),
728 ("digital", "Digital Download", "Files, archives, documents"),
729 ("video", "Video", "Tutorials, films, recordings"),
730 ("course", "Course", "Multi-part lessons, curricula"),
731 ("plugin", "Plugin", "Software extensions, add-ons"),
732 ("sample", "Sample Pack", "Audio samples, loops, one-shots"),
733 ("preset", "Preset Pack", "Synth presets, effect chains"),
734 ("template", "Template", "Design templates, starter kits"),
735 ("image", "Image", "Photos, artwork, graphics"),
736 ("bundle", "Bundle", "Collection of other items"),
737 ]
738 }
739
740 /// Item type cards filtered to one per distinct wizard behavior group.
741 ///
742 /// The wizard only needs a type selector when the allowed types produce
743 /// different content-step UIs (text editor vs audio upload vs file upload).
744 /// Returns one card per group, using the first allowed type as the value.
745 /// If all types share one group, returns a single card (caller should skip
746 /// the type step entirely).
747 pub fn wizard_type_cards(
748 features: &[String],
749 ) -> Vec<(&'static str, &'static str, &'static str)> {
750 let allowed = Self::allowed_item_type_cards(features);
751 let mut seen_groups = std::collections::HashSet::new();
752 let mut cards = Vec::new();
753
754 for (value, _, _) in &allowed {
755 let Ok(item_type) = value.parse::<ItemType>() else {
756 continue;
757 };
758 let group = item_type.wizard_group();
759 if seen_groups.insert(group) {
760 let (label, desc) = match group {
761 "text" => ("Text", "Write in the editor"),
762 "audio" => ("Audio", "Upload audio files"),
763 "video" => ("Video", "Upload video files"),
764 "bundle" => ("Bundle", "Collection of other items"),
765 _ => ("File", "Upload any file"),
766 };
767 cards.push((*value, label, desc));
768 }
769 }
770
771 cards
772 }
773 }
774
775 // ── Projects ──
776
777 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
778 #[serde(rename_all = "lowercase")]
779 pub enum ProjectType {
780 Blog,
781 Book,
782 Podcast,
783 Course,
784 Music,
785 Software,
786 Art,
787 Writing,
788 #[default]
789 General,
790 }
791
792 impl_str_enum!(ProjectType {
793 Blog => "blog",
794 Book => "book",
795 Podcast => "podcast",
796 Course => "course",
797 Music => "music",
798 Software => "software",
799 Art => "art",
800 Writing => "writing",
801 General => "general",
802 });
803
804 impl ProjectType {
805 /// Human-readable label for display.
806 pub fn label(&self) -> &'static str {
807 match self {
808 Self::Blog => "Blog",
809 Self::Book => "Book",
810 Self::Podcast => "Podcast",
811 Self::Course => "Course",
812 Self::Music => "Music",
813 Self::Software => "Software",
814 Self::Art => "Art",
815 Self::Writing => "Writing",
816 Self::General => "General",
817 }
818 }
819
820 /// All valid project types as (value, label) pairs for form rendering.
821 pub fn all() -> &'static [(&'static str, &'static str)] {
822 &[
823 ("blog", "Blog"),
824 ("book", "Book"),
825 ("podcast", "Podcast"),
826 ("course", "Course"),
827 ("music", "Music"),
828 ("software", "Software"),
829 ("art", "Art"),
830 ("writing", "Writing"),
831 ("general", "General"),
832 ]
833 }
834 }
835
836 // ── Build Pipeline ──
837
838 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
839 #[serde(rename_all = "snake_case")]
840 pub enum BuildStatus {
841 Pending,
842 Running,
843 Succeeded,
844 Failed,
845 Cancelled,
846 }
847
848 impl_str_enum!(BuildStatus {
849 Pending => "pending",
850 Running => "running",
851 Succeeded => "succeeded",
852 Failed => "failed",
853 Cancelled => "cancelled",
854 });
855
856 // ── Project Pricing ──
857
858 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
859 #[serde(rename_all = "snake_case")]
860 pub enum PricingKind {
861 #[default]
862 Free,
863 BuyOnce,
864 Pwyw,
865 Subscription,
866 }
867
868 impl_str_enum!(PricingKind {
869 Free => "free",
870 BuyOnce => "buy_once",
871 Pwyw => "pwyw",
872 Subscription => "subscription",
873 });
874
875 // ── Mailing Lists ──
876
877 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
878 #[serde(rename_all = "lowercase")]
879 pub enum MailingListType {
880 Content,
881 Devlog,
882 Patches,
883 }
884
885 impl_str_enum!(MailingListType {
886 Content => "content",
887 Devlog => "devlog",
888 Patches => "patches",
889 });
890
891 // ── Import System ──
892
893 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
894 #[serde(rename_all = "snake_case")]
895 pub enum ImportSource {
896 GenericCsv,
897 Substack,
898 Ghost,
899 Gumroad,
900 Bandcamp,
901 LemonSqueezy,
902 Patreon,
903 }
904
905 impl_str_enum!(ImportSource {
906 GenericCsv => "generic_csv",
907 Substack => "substack",
908 Ghost => "ghost",
909 Gumroad => "gumroad",
910 Bandcamp => "bandcamp",
911 LemonSqueezy => "lemon_squeezy",
912 Patreon => "patreon",
913 });
914
915 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
916 #[serde(rename_all = "lowercase")]
917 pub enum ImportJobStatus {
918 Pending,
919 Processing,
920 Completed,
921 Failed,
922 }
923
924 impl_str_enum!(ImportJobStatus {
925 Pending => "pending",
926 Processing => "processing",
927 Completed => "completed",
928 Failed => "failed",
929 });
930
931 // -- Moderation action types --------------------------------------------------
932
933 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
934 pub enum ModerationActionType {
935 Warning,
936 Suspension,
937 Termination,
938 ContentRemoval,
939 }
940
941 impl_str_enum!(ModerationActionType {
942 Warning => "warning",
943 Suspension => "suspension",
944 Termination => "termination",
945 ContentRemoval => "content_removal",
946 });
947
948 // -- Checkout types (Stripe metadata) -----------------------------------------
949
950 /// Discriminator for checkout session types stored in Stripe metadata.
951 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
952 pub enum CheckoutType {
953 Guest,
954 Subscription,
955 Tip,
956 FanPlus,
957 CreatorTier,
958 Cart,
959 SynckitAppSub,
960 }
961
962 impl_str_enum!(CheckoutType {
963 Guest => "guest",
964 Subscription => "subscription",
965 Tip => "tip",
966 FanPlus => "fan_plus",
967 CreatorTier => "creator_tier",
968 Cart => "cart",
969 SynckitAppSub => "synckit_app_sub",
970 });
971
972 impl ModerationActionType {
973 pub fn label(&self) -> &'static str {
974 match self {
975 Self::Warning => "Warning",
976 Self::Suspension => "Suspension",
977 Self::Termination => "Termination",
978 Self::ContentRemoval => "Content Removal",
979 }
980 }
981 }
982
983 #[cfg(test)]
984 mod tests {
985 use super::*;
986
987 #[test]
988 fn discount_type_round_trip() {
989 assert_eq!(DiscountType::Percentage.to_string(), "percentage");
990 assert_eq!("fixed".parse::<DiscountType>().unwrap(), DiscountType::Fixed);
991 assert!("bogus".parse::<DiscountType>().is_err());
992 }
993
994 #[test]
995 fn waitlist_status_round_trip() {
996 assert_eq!(WaitlistStatus::Pending.to_string(), "pending");
997 assert_eq!("approved".parse::<WaitlistStatus>().unwrap(), WaitlistStatus::Approved);
998 }
999
1000 #[test]
1001 fn selection_method_round_trip() {
1002 assert_eq!(SelectionMethod::HandPicked.to_string(), "hand_picked");
1003 assert_eq!("lottery".parse::<SelectionMethod>().unwrap(), SelectionMethod::Lottery);
1004 assert_eq!(SelectionMethod::Invited.to_string(), "invited");
1005 assert_eq!("invited".parse::<SelectionMethod>().unwrap(), SelectionMethod::Invited);
1006 }
1007
1008 #[test]
1009 fn transaction_status_round_trip() {
1010 assert_eq!(TransactionStatus::Completed.to_string(), "completed");
1011 assert_eq!("refunded".parse::<TransactionStatus>().unwrap(), TransactionStatus::Refunded);
1012 }
1013
1014 #[test]
1015 fn follow_target_type_round_trip() {
1016 assert_eq!(FollowTargetType::User.to_string(), "user");
1017 assert_eq!("tag".parse::<FollowTargetType>().unwrap(), FollowTargetType::Tag);
1018 }
1019
1020 #[test]
1021 fn subscription_status_round_trip() {
1022 assert_eq!(SubscriptionStatus::PastDue.to_string(), "past_due");
1023 assert_eq!("canceled".parse::<SubscriptionStatus>().unwrap(), SubscriptionStatus::Canceled);
1024 assert_eq!(SubscriptionStatus::Trialing.to_string(), "trialing");
1025 assert_eq!("trialing".parse::<SubscriptionStatus>().unwrap(), SubscriptionStatus::Trialing);
1026 assert_eq!(SubscriptionStatus::Incomplete.to_string(), "incomplete");
1027 assert_eq!("incomplete".parse::<SubscriptionStatus>().unwrap(), SubscriptionStatus::Incomplete);
1028 assert_eq!(SubscriptionStatus::IncompleteExpired.to_string(), "incomplete_expired");
1029 assert_eq!("incomplete_expired".parse::<SubscriptionStatus>().unwrap(), SubscriptionStatus::IncompleteExpired);
1030 }
1031
1032 #[test]
1033 fn sync_operation_round_trip() {
1034 assert_eq!(SyncOperation::Insert.to_string(), "INSERT");
1035 assert_eq!("DELETE".parse::<SyncOperation>().unwrap(), SyncOperation::Delete);
1036 }
1037
1038 #[test]
1039 fn sync_platform_round_trip() {
1040 assert_eq!(SyncPlatform::Macos.to_string(), "macos");
1041 assert_eq!("web".parse::<SyncPlatform>().unwrap(), SyncPlatform::Web);
1042 }
1043
1044 #[test]
1045 fn item_type_round_trip() {
1046 assert_eq!(ItemType::Audio.to_string(), "audio");
1047 assert_eq!("plugin".parse::<ItemType>().unwrap(), ItemType::Plugin);
1048 assert_eq!(ItemType::Bundle.to_string(), "bundle");
1049 assert_eq!("bundle".parse::<ItemType>().unwrap(), ItemType::Bundle);
1050 }
1051
1052 #[test]
1053 fn insertion_position_round_trip() {
1054 assert_eq!(InsertionPosition::PreRoll.to_string(), "pre_roll");
1055 assert_eq!("mid_roll".parse::<InsertionPosition>().unwrap(), InsertionPosition::MidRoll);
1056 assert_eq!("post_roll".parse::<InsertionPosition>().unwrap(), InsertionPosition::PostRoll);
1057 assert!("invalid".parse::<InsertionPosition>().is_err());
1058 }
1059
1060 #[test]
1061 fn item_type_label() {
1062 assert_eq!(ItemType::Audio.label(), "Audio");
1063 assert_eq!(ItemType::Plugin.label(), "Plugin");
1064 assert_eq!(ItemType::Template.label(), "Template");
1065 }
1066
1067 #[test]
1068 fn appeal_decision_round_trip() {
1069 assert_eq!(AppealDecision::Approved.to_string(), "approved");
1070 assert_eq!("denied".parse::<AppealDecision>().unwrap(), AppealDecision::Denied);
1071 assert!("bogus".parse::<AppealDecision>().is_err());
1072 }
1073
1074 #[test]
1075 fn discover_sort_round_trip() {
1076 assert_eq!(DiscoverSort::Newest.to_string(), "newest");
1077 assert_eq!("most_sold".parse::<DiscoverSort>().unwrap(), DiscoverSort::MostSold);
1078 assert_eq!("price_asc".parse::<DiscoverSort>().unwrap(), DiscoverSort::PriceAsc);
1079 assert_eq!("price_desc".parse::<DiscoverSort>().unwrap(), DiscoverSort::PriceDesc);
1080 assert!("invalid".parse::<DiscoverSort>().is_err());
1081 }
1082
1083 #[test]
1084 fn file_scan_status_round_trip() {
1085 assert_eq!(FileScanStatus::Clean.to_string(), "clean");
1086 assert_eq!(FileScanStatus::Pending.to_string(), "pending");
1087 assert_eq!(FileScanStatus::Scanning.to_string(), "scanning");
1088 assert_eq!("pending".parse::<FileScanStatus>().unwrap(), FileScanStatus::Pending);
1089 assert_eq!("scanning".parse::<FileScanStatus>().unwrap(), FileScanStatus::Scanning);
1090 assert_eq!("held_for_review".parse::<FileScanStatus>().unwrap(), FileScanStatus::HeldForReview);
1091 assert_eq!(FileScanStatus::HeldForReview.to_string(), "held_for_review");
1092 assert_eq!("quarantined".parse::<FileScanStatus>().unwrap(), FileScanStatus::Quarantined);
1093 assert!("bogus".parse::<FileScanStatus>().is_err());
1094 }
1095
1096 #[test]
1097 fn code_purpose_round_trip() {
1098 assert_eq!(CodePurpose::Discount.to_string(), "discount");
1099 assert_eq!("free_access".parse::<CodePurpose>().unwrap(), CodePurpose::FreeAccess);
1100 assert_eq!("free_trial".parse::<CodePurpose>().unwrap(), CodePurpose::FreeTrial);
1101 assert!("bogus".parse::<CodePurpose>().is_err());
1102 }
1103
1104 #[test]
1105 fn issue_status_round_trip() {
1106 assert_eq!(IssueStatus::Open.to_string(), "open");
1107 assert_eq!("closed".parse::<IssueStatus>().unwrap(), IssueStatus::Closed);
1108 assert!("bogus".parse::<IssueStatus>().is_err());
1109 }
1110
1111 #[test]
1112 fn report_target_type_round_trip() {
1113 assert_eq!(ReportTargetType::Project.to_string(), "project");
1114 assert_eq!("item".parse::<ReportTargetType>().unwrap(), ReportTargetType::Item);
1115 assert!("bogus".parse::<ReportTargetType>().is_err());
1116 }
1117
1118 #[test]
1119 fn report_type_round_trip() {
1120 assert_eq!(ReportType::Mislabeled.to_string(), "mislabeled");
1121 assert_eq!("spam".parse::<ReportType>().unwrap(), ReportType::Spam);
1122 assert_eq!("abuse".parse::<ReportType>().unwrap(), ReportType::Abuse);
1123 assert_eq!("infringement".parse::<ReportType>().unwrap(), ReportType::Infringement);
1124 assert_eq!("other".parse::<ReportType>().unwrap(), ReportType::Other);
1125 assert!("bogus".parse::<ReportType>().is_err());
1126 }
1127
1128 #[test]
1129 fn report_status_round_trip() {
1130 assert_eq!(ReportStatus::Open.to_string(), "open");
1131 assert_eq!("resolved".parse::<ReportStatus>().unwrap(), ReportStatus::Resolved);
1132 assert_eq!("dismissed".parse::<ReportStatus>().unwrap(), ReportStatus::Dismissed);
1133 assert!("bogus".parse::<ReportStatus>().is_err());
1134 }
1135
1136 #[test]
1137 fn creator_tier_round_trip() {
1138 assert_eq!(CreatorTier::Basic.to_string(), "basic");
1139 assert_eq!("small_files".parse::<CreatorTier>().unwrap(), CreatorTier::SmallFiles);
1140 assert_eq!("big_files".parse::<CreatorTier>().unwrap(), CreatorTier::BigFiles);
1141 assert_eq!("everything".parse::<CreatorTier>().unwrap(), CreatorTier::Everything);
1142 assert!("bogus".parse::<CreatorTier>().is_err());
1143 }
1144
1145 #[test]
1146 fn creator_tier_label_and_price() {
1147 assert_eq!(CreatorTier::Basic.label(), "Basic");
1148 assert_eq!(CreatorTier::SmallFiles.label(), "Small Files");
1149 assert_eq!(CreatorTier::Basic.price_cents(), 1600);
1150 assert_eq!(CreatorTier::Everything.price_cents(), 6000);
1151 }
1152
1153 #[test]
1154 fn creator_tier_file_limits() {
1155 assert_eq!(CreatorTier::Basic.max_file_bytes(), 10 * 1024 * 1024);
1156 assert_eq!(CreatorTier::SmallFiles.max_file_bytes(), 500 * 1024 * 1024);
1157 assert_eq!(CreatorTier::BigFiles.max_file_bytes(), 20 * 1024 * 1024 * 1024);
1158 assert_eq!(CreatorTier::Everything.max_file_bytes(), 20 * 1024 * 1024 * 1024);
1159 }
1160
1161 #[test]
1162 fn creator_tier_storage_limits() {
1163 assert_eq!(CreatorTier::Basic.max_storage_bytes(), 50 * 1024 * 1024 * 1024);
1164 assert_eq!(CreatorTier::SmallFiles.max_storage_bytes(), 250 * 1024 * 1024 * 1024);
1165 assert_eq!(CreatorTier::BigFiles.max_storage_bytes(), 500 * 1024 * 1024 * 1024);
1166 assert_eq!(CreatorTier::Everything.max_storage_bytes(), 500 * 1024 * 1024 * 1024);
1167 }
1168
1169 #[test]
1170 fn creator_tier_allows_file_uploads() {
1171 assert!(!CreatorTier::Basic.allows_file_uploads());
1172 assert!(CreatorTier::SmallFiles.allows_file_uploads());
1173 assert!(CreatorTier::BigFiles.allows_file_uploads());
1174 assert!(CreatorTier::Everything.allows_file_uploads());
1175 }
1176
1177 #[test]
1178 fn creator_tier_features_track_live_capabilities() {
1179 assert!(CreatorTier::Basic.features().is_empty());
1180 assert_eq!(CreatorTier::SmallFiles.features(), &["file_uploads"]);
1181 assert_eq!(CreatorTier::BigFiles.features(), &["file_uploads", "large_files"]);
1182 assert_eq!(CreatorTier::Everything.features(), &["file_uploads", "large_files"]);
1183 }
1184
1185 #[test]
1186 fn project_feature_round_trip() {
1187 assert_eq!(ProjectFeature::Audio.to_string(), "audio");
1188 assert_eq!("downloads".parse::<ProjectFeature>().unwrap(), ProjectFeature::Downloads);
1189 assert_eq!("license_keys".parse::<ProjectFeature>().unwrap(), ProjectFeature::LicenseKeys);
1190 assert_eq!("source_code".parse::<ProjectFeature>().unwrap(), ProjectFeature::SourceCode);
1191 assert!("bogus".parse::<ProjectFeature>().is_err());
1192 }
1193
1194 #[test]
1195 fn project_feature_label_and_description() {
1196 assert_eq!(ProjectFeature::Audio.label(), "Audio");
1197 assert_eq!(ProjectFeature::LicenseKeys.label(), "License Keys");
1198 assert!(!ProjectFeature::Audio.description().is_empty());
1199 }
1200
1201 #[test]
1202 fn project_feature_all() {
1203 let all = ProjectFeature::all();
1204 assert_eq!(all.len(), 8);
1205 assert_eq!(all[0].0, "audio");
1206 assert_eq!(all[7].0, "cloud_sync");
1207 }
1208
1209 #[test]
1210 fn project_feature_allowed_item_types_audio() {
1211 let types = ProjectFeature::Audio.allowed_item_types();
1212 assert!(types.contains(&ItemType::Audio));
1213 assert!(types.contains(&ItemType::Sample));
1214 assert!(types.contains(&ItemType::Preset));
1215 assert!(!types.contains(&ItemType::Text));
1216 }
1217
1218 #[test]
1219 fn project_feature_allowed_item_types_downloads() {
1220 let types = ProjectFeature::Downloads.allowed_item_types();
1221 assert!(types.contains(&ItemType::Digital));
1222 assert!(types.contains(&ItemType::Plugin));
1223 assert!(types.contains(&ItemType::Video));
1224 assert!(!types.contains(&ItemType::Audio));
1225 }
1226
1227 #[test]
1228 fn project_feature_allowed_item_types_text() {
1229 let types = ProjectFeature::Text.allowed_item_types();
1230 assert!(types.contains(&ItemType::Text));
1231 assert_eq!(types.len(), 1);
1232 }
1233
1234 #[test]
1235 fn project_feature_allowed_item_types_non_content() {
1236 assert!(ProjectFeature::Blog.allowed_item_types().is_empty());
1237 assert!(ProjectFeature::Subscriptions.allowed_item_types().is_empty());
1238 assert!(ProjectFeature::LicenseKeys.allowed_item_types().is_empty());
1239 assert!(ProjectFeature::SourceCode.allowed_item_types().is_empty());
1240 assert!(ProjectFeature::CloudSync.allowed_item_types().is_empty());
1241 }
1242
1243 #[test]
1244 fn project_feature_allowed_cards_filtered() {
1245 let cards = ProjectFeature::allowed_item_type_cards(&["audio".into()]);
1246 let values: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
1247 assert!(values.contains(&"audio"));
1248 assert!(values.contains(&"sample"));
1249 assert!(values.contains(&"preset"));
1250 assert!(values.contains(&"bundle")); // Bundle always included
1251 assert!(!values.contains(&"text"));
1252 assert!(!values.contains(&"digital"));
1253 }
1254
1255 #[test]
1256 fn project_feature_allowed_cards_combined() {
1257 let cards = ProjectFeature::allowed_item_type_cards(&["audio".into(), "text".into()]);
1258 let values: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
1259 assert!(values.contains(&"audio"));
1260 assert!(values.contains(&"text"));
1261 assert!(values.contains(&"bundle")); // Bundle always included
1262 assert!(!values.contains(&"digital"));
1263 }
1264
1265 #[test]
1266 fn project_feature_allowed_cards_empty_features_shows_all() {
1267 let cards = ProjectFeature::allowed_item_type_cards(&[]);
1268 assert_eq!(cards.len(), 11); // 10 content types + bundle
1269 }
1270
1271 #[test]
1272 fn project_feature_allowed_cards_non_content_features_shows_all() {
1273 let cards = ProjectFeature::allowed_item_type_cards(&["blog".into(), "subscriptions".into()]);
1274 // Blog and subscriptions don't gate item types, so all should be shown
1275 assert_eq!(cards.len(), 11); // 10 content types + bundle
1276 }
1277
1278 #[test]
1279 fn project_feature_derive_type() {
1280 assert_eq!(
1281 ProjectFeature::derive_project_type(&["audio".into(), "blog".into()]),
1282 ProjectType::Music,
1283 );
1284 assert_eq!(
1285 ProjectFeature::derive_project_type(&["text".into()]),
1286 ProjectType::Blog,
1287 );
1288 assert_eq!(
1289 ProjectFeature::derive_project_type(&["downloads".into(), "text".into()]),
1290 ProjectType::Software,
1291 );
1292 assert_eq!(
1293 ProjectFeature::derive_project_type(&["subscriptions".into()]),
1294 ProjectType::General,
1295 );
1296 }
1297
1298 #[test]
1299 fn project_type_round_trip() {
1300 assert_eq!(ProjectType::Blog.to_string(), "blog");
1301 assert_eq!("software".parse::<ProjectType>().unwrap(), ProjectType::Software);
1302 assert_eq!("general".parse::<ProjectType>().unwrap(), ProjectType::General);
1303 assert_eq!(ProjectType::default(), ProjectType::General);
1304 assert!("bogus".parse::<ProjectType>().is_err());
1305 }
1306
1307 #[test]
1308 fn project_type_label() {
1309 assert_eq!(ProjectType::Blog.label(), "Blog");
1310 assert_eq!(ProjectType::Software.label(), "Software");
1311 assert_eq!(ProjectType::General.label(), "General");
1312 }
1313
1314 #[test]
1315 fn project_type_all() {
1316 let all = ProjectType::all();
1317 assert_eq!(all.len(), 9);
1318 assert_eq!(all[0], ("blog", "Blog"));
1319 assert_eq!(all[8], ("general", "General"));
1320 }
1321
1322 #[test]
1323 fn build_status_round_trip() {
1324 assert_eq!(BuildStatus::Pending.to_string(), "pending");
1325 assert_eq!("running".parse::<BuildStatus>().unwrap(), BuildStatus::Running);
1326 assert_eq!("succeeded".parse::<BuildStatus>().unwrap(), BuildStatus::Succeeded);
1327 assert_eq!("failed".parse::<BuildStatus>().unwrap(), BuildStatus::Failed);
1328 assert_eq!("cancelled".parse::<BuildStatus>().unwrap(), BuildStatus::Cancelled);
1329 assert!("bogus".parse::<BuildStatus>().is_err());
1330 }
1331
1332 #[test]
1333 fn serde_json_round_trip() {
1334 let dt = DiscountType::Percentage;
1335 let json = serde_json::to_string(&dt).unwrap();
1336 assert_eq!(json, "\"percentage\"");
1337 let back: DiscountType = serde_json::from_str(&json).unwrap();
1338 assert_eq!(back, dt);
1339 }
1340
1341 #[test]
1342 fn pricing_kind_round_trip() {
1343 assert_eq!(PricingKind::Free.to_string(), "free");
1344 assert_eq!("buy_once".parse::<PricingKind>().unwrap(), PricingKind::BuyOnce);
1345 assert_eq!("pwyw".parse::<PricingKind>().unwrap(), PricingKind::Pwyw);
1346 assert_eq!("subscription".parse::<PricingKind>().unwrap(), PricingKind::Subscription);
1347 assert_eq!(PricingKind::default(), PricingKind::Free);
1348 assert!("bogus".parse::<PricingKind>().is_err());
1349 }
1350
1351 #[test]
1352 fn mailing_list_type_round_trip() {
1353 assert_eq!(MailingListType::Content.to_string(), "content");
1354 assert_eq!("devlog".parse::<MailingListType>().unwrap(), MailingListType::Devlog);
1355 assert_eq!("patches".parse::<MailingListType>().unwrap(), MailingListType::Patches);
1356 assert!("bogus".parse::<MailingListType>().is_err());
1357 }
1358
1359 #[test]
1360 fn serde_json_subscription_status() {
1361 let s = SubscriptionStatus::PastDue;
1362 let json = serde_json::to_string(&s).unwrap();
1363 assert_eq!(json, "\"past_due\"");
1364 let back: SubscriptionStatus = serde_json::from_str(&json).unwrap();
1365 assert_eq!(back, s);
1366
1367 let t = SubscriptionStatus::Trialing;
1368 let json = serde_json::to_string(&t).unwrap();
1369 assert_eq!(json, "\"trialing\"");
1370 let back: SubscriptionStatus = serde_json::from_str(&json).unwrap();
1371 assert_eq!(back, t);
1372 }
1373
1374 // ── ItemType::wizard_group ──
1375
1376 #[test]
1377 fn wizard_group_text() {
1378 assert_eq!(ItemType::Text.wizard_group(), "text");
1379 }
1380
1381 #[test]
1382 fn wizard_group_audio() {
1383 assert_eq!(ItemType::Audio.wizard_group(), "audio");
1384 }
1385
1386 #[test]
1387 fn wizard_group_video() {
1388 assert_eq!(ItemType::Video.wizard_group(), "video");
1389 }
1390
1391 #[test]
1392 fn wizard_group_file_types() {
1393 for t in [
1394 ItemType::Digital,
1395 ItemType::Course,
1396 ItemType::Plugin,
1397 ItemType::Sample,
1398 ItemType::Preset,
1399 ItemType::Template,
1400 ItemType::Image,
1401 ] {
1402 assert_eq!(t.wizard_group(), "file", "{t:?} should be in file group");
1403 }
1404 }
1405
1406 #[test]
1407 fn wizard_group_bundle() {
1408 assert_eq!(ItemType::Bundle.wizard_group(), "bundle");
1409 }
1410
1411 // ── ProjectFeature::wizard_type_cards ──
1412
1413 #[test]
1414 fn wizard_cards_text_only_two_groups() {
1415 // Text + bundle (bundle always included)
1416 let cards = ProjectFeature::wizard_type_cards(&["text".into()]);
1417 assert_eq!(cards.len(), 2);
1418 let groups: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
1419 assert!(groups.contains(&"text"));
1420 assert!(groups.contains(&"bundle"));
1421 }
1422
1423 #[test]
1424 fn wizard_cards_downloads_three_groups() {
1425 // Download types split into "file" + "video" groups + bundle → 3 cards
1426 let cards = ProjectFeature::wizard_type_cards(&["downloads".into()]);
1427 assert_eq!(cards.len(), 3);
1428 let groups: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
1429 assert!(groups.contains(&"digital")); // first type in file group
1430 assert!(groups.contains(&"video")); // video group
1431 assert!(groups.contains(&"bundle"));
1432 }
1433
1434 #[test]
1435 fn wizard_cards_audio_feature_three_groups() {
1436 // Audio feature allows audio (audio group) + sample, preset (file group) + bundle
1437 let cards = ProjectFeature::wizard_type_cards(&["audio".into()]);
1438 assert_eq!(cards.len(), 3);
1439 let groups: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
1440 assert!(groups.contains(&"audio"));
1441 assert!(groups.contains(&"sample")); // first file-group type
1442 assert!(groups.contains(&"bundle"));
1443 }
1444
1445 #[test]
1446 fn wizard_cards_text_and_audio_four_groups() {
1447 let cards =
1448 ProjectFeature::wizard_type_cards(&["text".into(), "audio".into()]);
1449 assert_eq!(cards.len(), 4); // text, audio, file, bundle
1450 }
1451
1452 #[test]
1453 fn wizard_cards_empty_features_all_five_groups() {
1454 // No content features → all types → 5 wizard groups (text, audio, video, file, bundle)
1455 let cards = ProjectFeature::wizard_type_cards(&[]);
1456 assert_eq!(cards.len(), 5);
1457 }
1458
1459 #[test]
1460 fn ai_tier_round_trip() {
1461 assert_eq!(AiTier::Handmade.to_string(), "handmade");
1462 assert_eq!("assisted".parse::<AiTier>().unwrap(), AiTier::Assisted);
1463 assert_eq!("generated".parse::<AiTier>().unwrap(), AiTier::Generated);
1464 assert!("bogus".parse::<AiTier>().is_err());
1465 }
1466
1467 #[test]
1468 fn ai_tier_label() {
1469 assert_eq!(AiTier::Handmade.label(), "Handmade");
1470 assert_eq!(AiTier::Assisted.label(), "Assisted");
1471 assert_eq!(AiTier::Generated.label(), "Generated");
1472 }
1473
1474 #[test]
1475 fn import_source_round_trip() {
1476 assert_eq!(ImportSource::GenericCsv.to_string(), "generic_csv");
1477 assert_eq!("substack".parse::<ImportSource>().unwrap(), ImportSource::Substack);
1478 assert_eq!("ghost".parse::<ImportSource>().unwrap(), ImportSource::Ghost);
1479 assert_eq!("gumroad".parse::<ImportSource>().unwrap(), ImportSource::Gumroad);
1480 assert_eq!("bandcamp".parse::<ImportSource>().unwrap(), ImportSource::Bandcamp);
1481 assert_eq!("lemon_squeezy".parse::<ImportSource>().unwrap(), ImportSource::LemonSqueezy);
1482 assert_eq!("patreon".parse::<ImportSource>().unwrap(), ImportSource::Patreon);
1483 assert!("bogus".parse::<ImportSource>().is_err());
1484 }
1485
1486 #[test]
1487 fn import_job_status_round_trip() {
1488 assert_eq!(ImportJobStatus::Pending.to_string(), "pending");
1489 assert_eq!("processing".parse::<ImportJobStatus>().unwrap(), ImportJobStatus::Processing);
1490 assert_eq!("completed".parse::<ImportJobStatus>().unwrap(), ImportJobStatus::Completed);
1491 assert_eq!("failed".parse::<ImportJobStatus>().unwrap(), ImportJobStatus::Failed);
1492 assert!("bogus".parse::<ImportJobStatus>().is_err());
1493 }
1494
1495 #[test]
1496 fn checkout_type_round_trip() {
1497 assert_eq!(CheckoutType::Guest.to_string(), "guest");
1498 assert_eq!(CheckoutType::Subscription.to_string(), "subscription");
1499 assert_eq!(CheckoutType::Tip.to_string(), "tip");
1500 assert_eq!(CheckoutType::FanPlus.to_string(), "fan_plus");
1501 assert_eq!(CheckoutType::CreatorTier.to_string(), "creator_tier");
1502 assert_eq!("guest".parse::<CheckoutType>().unwrap(), CheckoutType::Guest);
1503 assert_eq!("fan_plus".parse::<CheckoutType>().unwrap(), CheckoutType::FanPlus);
1504 assert!("bogus".parse::<CheckoutType>().is_err());
1505 }
1506
1507 #[test]
1508 fn moderation_action_type_round_trip() {
1509 assert_eq!(ModerationActionType::Warning.to_string(), "warning");
1510 assert_eq!(ModerationActionType::Suspension.to_string(), "suspension");
1511 assert_eq!(ModerationActionType::Termination.to_string(), "termination");
1512 assert_eq!(ModerationActionType::ContentRemoval.to_string(), "content_removal");
1513 assert_eq!("warning".parse::<ModerationActionType>().unwrap(), ModerationActionType::Warning);
1514 assert_eq!("content_removal".parse::<ModerationActionType>().unwrap(), ModerationActionType::ContentRemoval);
1515 assert!("bogus".parse::<ModerationActionType>().is_err());
1516 }
1517 }
1518