Skip to main content

max / makenotwork

59.5 KB · 1957 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 impl $enum_name {
85 /// Every wire/DB string this enum maps to, the single source of
86 /// truth for the variant set. For each enum *registered* in the
87 /// enum-drift integration test (`tests/workflows/enum_drift.rs`),
88 /// this set is asserted equal to the Postgres `CHECK (... IN (...))`
89 /// list on its backing column, so a variant added here without
90 /// widening the DB constraint (or vice versa) fails at test time
91 /// rather than at the first read of a poisoned row. Coverage is that
92 /// registry, not every enum automatically: add a `(enum, table,
93 /// column)` row there when a new CHECK-constrained column lands.
94 pub const VARIANTS: &'static [&'static str] = &[$($str),+];
95 }
96 };
97 }
98
99 // ── Discount codes ──
100
101 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102 #[serde(rename_all = "lowercase")]
103 pub enum DiscountType {
104 Percentage,
105 Fixed,
106 }
107
108 impl_str_enum!(DiscountType {
109 Percentage => "percentage",
110 Fixed => "fixed",
111 });
112
113 // ── Promo codes ──
114
115 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116 #[serde(rename_all = "snake_case")]
117 pub enum CodePurpose {
118 Discount,
119 FreeAccess,
120 FreeTrial,
121 }
122
123 impl_str_enum!(CodePurpose {
124 Discount => "discount",
125 FreeAccess => "free_access",
126 FreeTrial => "free_trial",
127 });
128
129 // ── Waitlist ──
130
131 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
132 #[serde(rename_all = "lowercase")]
133 pub enum WaitlistStatus {
134 Pending,
135 Approved,
136 Spam,
137 }
138
139 impl_str_enum!(WaitlistStatus {
140 Pending => "pending",
141 Approved => "approved",
142 Spam => "spam",
143 });
144
145 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
146 pub enum SelectionMethod {
147 #[serde(rename = "hand_picked")]
148 HandPicked,
149 #[serde(rename = "lottery")]
150 Lottery,
151 #[serde(rename = "invited")]
152 Invited,
153 }
154
155 impl_str_enum!(SelectionMethod {
156 HandPicked => "hand_picked",
157 Lottery => "lottery",
158 Invited => "invited",
159 });
160
161 // ── Transactions ──
162
163 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
164 #[serde(rename_all = "lowercase")]
165 pub enum TransactionStatus {
166 Pending,
167 Completed,
168 /// In-flight: a refund has been claimed (`completed -> refunding`) and sent to
169 /// Stripe, but the `refund.created` webhook has not yet finalized it. Guards
170 /// against double-submit on shared-cart PaymentIntents (Pay-S1, Run 9).
171 Refunding,
172 Refunded,
173 /// Present in the DB `CHECK` since the initial schema but never written by
174 /// the app (stale pending transactions are deleted, not failed). Kept as a
175 /// variant so the enum can decode any legacy/manual `'failed'` row instead of
176 /// fail-closed-poisoning the whole query, and so the enum-drift test's
177 /// variant set matches the column constraint.
178 Failed,
179 }
180
181 impl_str_enum!(TransactionStatus {
182 Pending => "pending",
183 Completed => "completed",
184 Refunding => "refunding",
185 Refunded => "refunded",
186 Failed => "failed",
187 });
188
189 // ── Follows ──
190
191 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
192 #[serde(rename_all = "lowercase")]
193 pub enum FollowTargetType {
194 User,
195 Project,
196 Tag,
197 }
198
199 impl_str_enum!(FollowTargetType {
200 User => "user",
201 Project => "project",
202 Tag => "tag",
203 });
204
205 // ── Subscriptions ──
206
207 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
208 pub enum SubscriptionStatus {
209 #[serde(rename = "active")]
210 Active,
211 #[serde(rename = "trialing")]
212 Trialing,
213 #[serde(rename = "incomplete")]
214 Incomplete,
215 #[serde(rename = "incomplete_expired")]
216 IncompleteExpired,
217 #[serde(rename = "past_due")]
218 PastDue,
219 #[serde(rename = "canceled")]
220 Canceled,
221 #[serde(rename = "unpaid")]
222 Unpaid,
223 }
224
225 impl_str_enum!(SubscriptionStatus {
226 Active => "active",
227 Trialing => "trialing",
228 Incomplete => "incomplete",
229 IncompleteExpired => "incomplete_expired",
230 PastDue => "past_due",
231 Canceled => "canceled",
232 Unpaid => "unpaid",
233 });
234
235 // ── SyncKit developer billing ──
236
237 /// Lifecycle of a SyncKit developer app's billing record (the `sync_apps.billing_status`
238 /// TEXT column, CHECK-constrained in migration 117). Replaces the raw string the
239 /// `DbSyncAppBilling` model used to carry, so a status comparison can't drift from the
240 /// CHECK set.
241 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
242 pub enum SyncBillingStatus {
243 #[serde(rename = "draft")]
244 Draft,
245 #[serde(rename = "active")]
246 Active,
247 #[serde(rename = "suspended_unpaid")]
248 SuspendedUnpaid,
249 #[serde(rename = "canceled")]
250 Canceled,
251 }
252
253 impl_str_enum!(SyncBillingStatus {
254 Draft => "draft",
255 Active => "active",
256 SuspendedUnpaid => "suspended_unpaid",
257 Canceled => "canceled",
258 });
259
260 /// How a SyncKit developer app's storage billing is enforced (the
261 /// `sync_apps.enforcement_mode` TEXT column, CHECK-constrained to `('per_key','bulk')`
262 /// in migration 118). Replaces the raw string the `DbSyncAppBilling` model used to
263 /// carry. Lifting this to an enum makes `monthly_price_cents` match exhaustively, so an
264 /// unrecognized mode is no longer silently priced at the floor (Pay-S2). The historical
265 /// `app_wide` value was renamed to `bulk` in migration 118; only these two are live.
266 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
267 pub enum SyncEnforcementMode {
268 #[serde(rename = "per_key")]
269 PerKey,
270 #[serde(rename = "bulk")]
271 Bulk,
272 }
273
274 impl_str_enum!(SyncEnforcementMode {
275 PerKey => "per_key",
276 Bulk => "bulk",
277 });
278
279 // ── Git repository visibility ──
280
281 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
282 #[serde(rename_all = "lowercase")]
283 pub enum Visibility {
284 Public,
285 Unlisted,
286 Private,
287 }
288
289 impl_str_enum!(Visibility {
290 Public => "public",
291 Unlisted => "unlisted",
292 Private => "private",
293 });
294
295 // ── Project member roles ──
296
297 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
298 #[serde(rename_all = "lowercase")]
299 pub enum ProjectRole {
300 Owner,
301 Member,
302 }
303
304 impl_str_enum!(ProjectRole {
305 Owner => "owner",
306 Member => "member",
307 });
308
309 // ── SyncKit ──
310
311 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
312 pub enum SyncOperation {
313 #[serde(rename = "INSERT")]
314 Insert,
315 #[serde(rename = "UPDATE")]
316 Update,
317 #[serde(rename = "DELETE")]
318 Delete,
319 }
320
321 impl_str_enum!(SyncOperation {
322 Insert => "INSERT",
323 Update => "UPDATE",
324 Delete => "DELETE",
325 });
326
327 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
328 #[serde(rename_all = "lowercase")]
329 pub enum SyncPlatform {
330 Macos,
331 Ios,
332 Android,
333 Windows,
334 Linux,
335 Web,
336 }
337
338 impl_str_enum!(SyncPlatform {
339 Macos => "macos",
340 Ios => "ios",
341 Android => "android",
342 Windows => "windows",
343 Linux => "linux",
344 Web => "web",
345 });
346
347 // ── File scanning ──
348
349 /// Status of an uploaded file in the scan pipeline.
350 ///
351 /// `Pending`, accepted, waiting in `scan_jobs` queue for a worker.
352 /// `Scanning`, worker has claimed the job and is running the pipeline.
353 /// `Clean`, pipeline completed, no Fail verdicts, no fail-closed Errors.
354 /// `HeldForReview`, pipeline completed with a fail-closed Error, OR the
355 /// uploader is untrusted (every untrusted upload routes to admin review).
356 /// `Quarantined`, pipeline returned a Fail verdict on at least one layer.
357 /// `Error`, pipeline itself crashed (worker exception, S3 fetch failed, etc.).
358 ///
359 /// State machine in `docs/scan-pipeline-audit.md`.
360 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
361 #[serde(rename_all = "snake_case")]
362 pub enum FileScanStatus {
363 Pending,
364 Scanning,
365 Clean,
366 Quarantined,
367 HeldForReview,
368 Error,
369 }
370
371 impl_str_enum!(FileScanStatus {
372 Pending => "pending",
373 Scanning => "scanning",
374 Clean => "clean",
375 Quarantined => "quarantined",
376 HeldForReview => "held_for_review",
377 Error => "error",
378 });
379
380 // ── Content Insertions ──
381
382 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
383 #[serde(rename_all = "snake_case")]
384 pub enum InsertionPosition {
385 PreRoll,
386 MidRoll,
387 PostRoll,
388 }
389
390 impl_str_enum!(InsertionPosition {
391 PreRoll => "pre_roll",
392 MidRoll => "mid_roll",
393 PostRoll => "post_roll",
394 });
395
396 // ── Appeals ──
397
398 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
399 #[serde(rename_all = "lowercase")]
400 pub enum AppealDecision {
401 Approved,
402 Denied,
403 }
404
405 impl_str_enum!(AppealDecision {
406 Approved => "approved",
407 Denied => "denied",
408 });
409
410 // ── Discover sorting ──
411
412 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
413 #[serde(rename_all = "snake_case")]
414 pub enum DiscoverSort {
415 Newest,
416 MostSold,
417 PriceAsc,
418 PriceDesc,
419 }
420
421 impl_str_enum!(DiscoverSort {
422 Newest => "newest",
423 MostSold => "most_sold",
424 PriceAsc => "price_asc",
425 PriceDesc => "price_desc",
426 });
427
428 // ── Items ──
429
430 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
431 #[serde(rename_all = "lowercase")]
432 pub enum ItemType {
433 Audio,
434 Text,
435 Video,
436 Image,
437 Plugin,
438 Preset,
439 Sample,
440 Course,
441 Template,
442 Digital,
443 Bundle,
444 }
445
446 impl_str_enum!(ItemType {
447 Audio => "audio",
448 Text => "text",
449 Video => "video",
450 Image => "image",
451 Plugin => "plugin",
452 Preset => "preset",
453 Sample => "sample",
454 Course => "course",
455 Template => "template",
456 Digital => "digital",
457 Bundle => "bundle",
458 });
459
460 impl ItemType {
461 /// Short human-readable label for display (replaces `helpers::get_item_type_label`).
462 pub fn label(&self) -> &'static str {
463 match self {
464 Self::Audio => "Audio",
465 Self::Text => "Text",
466 Self::Video => "Video",
467 Self::Image => "Image",
468 Self::Plugin => "Plugin",
469 Self::Preset => "Preset",
470 Self::Sample => "Sample",
471 Self::Course => "Course",
472 Self::Template => "Template",
473 Self::Digital => "Digital",
474 Self::Bundle => "Bundle",
475 }
476 }
477
478 /// Which wizard content-input group this type belongs to.
479 ///
480 /// Determines what the content step looks like:
481 /// - `"text"` → Markdown editor
482 /// - `"audio"` → Audio file upload
483 /// - `"video"` → Video file upload
484 /// - `"bundle"` → Item picker for bundle contents
485 /// - `"file"` → Generic file upload
486 pub fn wizard_group(&self) -> &'static str {
487 match self {
488 Self::Text => "text",
489 Self::Audio => "audio",
490 Self::Video => "video",
491 Self::Bundle => "bundle",
492 _ => "file",
493 }
494 }
495 }
496
497 // ── Git Issues ──
498
499 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
500 #[serde(rename_all = "lowercase")]
501 pub enum IssueStatus {
502 Open,
503 Closed,
504 }
505
506 impl_str_enum!(IssueStatus {
507 Open => "open",
508 Closed => "closed",
509 });
510
511 // ── Reports ──
512
513 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
514 #[serde(rename_all = "lowercase")]
515 pub enum ReportTargetType {
516 Project,
517 Item,
518 }
519
520 impl_str_enum!(ReportTargetType {
521 Project => "project",
522 Item => "item",
523 });
524
525 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
526 #[serde(rename_all = "lowercase")]
527 pub enum ReportType {
528 Mislabeled,
529 Spam,
530 Abuse,
531 Infringement,
532 Other,
533 }
534
535 impl_str_enum!(ReportType {
536 Mislabeled => "mislabeled",
537 Spam => "spam",
538 Abuse => "abuse",
539 Infringement => "infringement",
540 Other => "other",
541 });
542
543 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
544 #[serde(rename_all = "lowercase")]
545 pub enum ReportStatus {
546 Open,
547 Resolved,
548 Dismissed,
549 }
550
551 impl_str_enum!(ReportStatus {
552 Open => "open",
553 Resolved => "resolved",
554 Dismissed => "dismissed",
555 });
556
557 // ── Creator Tiers ──
558
559 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
560 #[serde(rename_all = "snake_case")]
561 pub enum CreatorTier {
562 Basic,
563 SmallFiles,
564 BigFiles,
565 Everything,
566 }
567
568 impl_str_enum!(CreatorTier {
569 Basic => "basic",
570 SmallFiles => "small_files",
571 BigFiles => "big_files",
572 Everything => "everything",
573 });
574
575 impl CreatorTier {
576 /// Human-readable label for display.
577 pub fn label(&self) -> &'static str {
578 match self {
579 Self::Basic => "Basic",
580 Self::SmallFiles => "Small Files",
581 Self::BigFiles => "Big Files",
582 Self::Everything => "Everything",
583 }
584 }
585
586 /// Monthly standard price in cents. Reads from the process-global
587 /// `TierPrices` installed at startup from `assumptions.toml`. See
588 /// `crate::tier_prices` for the OnceLock and test-setup helper.
589 pub fn price_cents(&self) -> i32 {
590 crate::tier_prices::TierPrices::global().price_cents_for(*self)
591 }
592
593 /// Maximum per-file upload size in bytes. Reads from the global
594 /// `TierPrices` (see `price_cents`).
595 pub fn max_file_bytes(&self) -> i64 {
596 crate::tier_prices::TierPrices::global().max_file_bytes_for(*self)
597 }
598
599 /// Maximum total storage in bytes. Reads from the global `TierPrices`
600 /// (see `price_cents`).
601 pub fn max_storage_bytes(&self) -> i64 {
602 crate::tier_prices::TierPrices::global().max_storage_bytes_for(*self)
603 }
604
605 /// Whether this tier allows non-cover file uploads (audio, downloads, insertions).
606 /// Basic is text-only; covers are always allowed regardless of tier.
607 pub fn allows_file_uploads(&self) -> bool {
608 !matches!(self, Self::Basic)
609 }
610
611 /// Capability strings exposed to external OAuth implementers via `/oauth/userinfo`.
612 ///
613 /// Implementers gate features on these strings rather than tier names so the
614 /// tier lineup can change without breaking callers. Only ship strings backed by
615 /// live behavior; new capabilities are added when they actually launch.
616 pub fn features(&self) -> &'static [&'static str] {
617 match self {
618 Self::Basic => &[],
619 Self::SmallFiles => &["file_uploads"],
620 Self::BigFiles => &["file_uploads", "large_files"],
621 Self::Everything => &["file_uploads", "large_files"],
622 }
623 }
624 }
625
626 // ── AI Tiers ──
627
628 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
629 #[serde(rename_all = "snake_case")]
630 pub enum AiTier {
631 Handmade,
632 Assisted,
633 Generated,
634 }
635
636 impl_str_enum!(AiTier {
637 Handmade => "handmade",
638 Assisted => "assisted",
639 Generated => "generated",
640 });
641
642 impl AiTier {
643 pub fn label(&self) -> &'static str {
644 match self {
645 Self::Handmade => "Handmade",
646 Self::Assisted => "Assisted",
647 Self::Generated => "Generated",
648 }
649 }
650 }
651
652 /// Discover-page filter shape per `about/generative-ai.md` § "How Fans
653 /// Use This". Distinct from `AiTier` because this is a *filter*, not a
654 /// per-item value: `HumanLed` aggregates the Handmade + Assisted tiers.
655 /// `None` on `DiscoverFilters.ai_tier` means "Everything", no
656 /// restriction.
657 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
658 pub enum AiTierFilter {
659 HandmadeOnly,
660 HumanLed,
661 }
662
663 impl_str_enum!(AiTierFilter {
664 HandmadeOnly => "handmade_only",
665 HumanLed => "human_led",
666 });
667
668 impl AiTierFilter {
669 pub fn label(&self) -> &'static str {
670 match self {
671 Self::HandmadeOnly => "Handmade only",
672 Self::HumanLed => "Human-led",
673 }
674 }
675 }
676
677 // ── Project Features ──
678
679 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
680 #[serde(rename_all = "snake_case")]
681 pub enum ProjectFeature {
682 Audio,
683 Downloads,
684 Text,
685 Blog,
686 Subscriptions,
687 LicenseKeys,
688 SourceCode,
689 CloudSync,
690 }
691
692 impl_str_enum!(ProjectFeature {
693 Audio => "audio",
694 Downloads => "downloads",
695 Text => "text",
696 Blog => "blog",
697 Subscriptions => "subscriptions",
698 LicenseKeys => "license_keys",
699 SourceCode => "source_code",
700 CloudSync => "cloud_sync",
701 });
702
703 impl ProjectFeature {
704 /// Human-readable label for display.
705 pub fn label(&self) -> &'static str {
706 match self {
707 Self::Audio => "Audio",
708 Self::Downloads => "Downloads",
709 Self::Text => "Text",
710 Self::Blog => "Blog",
711 Self::Subscriptions => "Subscriptions",
712 Self::LicenseKeys => "License Keys",
713 Self::SourceCode => "Source Code",
714 Self::CloudSync => "Cloud Sync",
715 }
716 }
717
718 /// One-line description of what this feature enables.
719 pub fn description(&self) -> &'static str {
720 match self {
721 Self::Audio => "Upload and stream audio files. Player with chapters.",
722 Self::Downloads => "Host file downloads with versioned releases.",
723 Self::Text => "Write and publish text content with markdown.",
724 Self::Blog => "Project blog with RSS feed.",
725 Self::Subscriptions => "Monthly subscriber tiers.",
726 Self::LicenseKeys => "Software license management with activation API.",
727 Self::SourceCode => "Git repository with source browser.",
728 Self::CloudSync => "E2E encrypted cloud sync for desktop and mobile apps.",
729 }
730 }
731
732 /// All features as (value, label, description) tuples for form rendering.
733 pub fn all() -> &'static [(&'static str, &'static str, &'static str)] {
734 &[
735 (
736 "audio",
737 "Audio",
738 "Upload and stream audio files. Player with chapters.",
739 ),
740 (
741 "downloads",
742 "Downloads",
743 "Host file downloads with versioned releases.",
744 ),
745 (
746 "text",
747 "Text",
748 "Write and publish text content with markdown.",
749 ),
750 ("blog", "Blog", "Project blog with RSS feed."),
751 (
752 "subscriptions",
753 "Subscriptions",
754 "Monthly subscriber tiers.",
755 ),
756 (
757 "license_keys",
758 "License Keys",
759 "Software license management with activation API.",
760 ),
761 (
762 "source_code",
763 "Source Code",
764 "Git repository with source browser.",
765 ),
766 (
767 "cloud_sync",
768 "Cloud Sync",
769 "E2E encrypted cloud sync for desktop and mobile apps.",
770 ),
771 ]
772 }
773
774 /// Derive the best-fit project type from a set of features.
775 pub fn derive_project_type(features: &[String]) -> ProjectType {
776 if features.iter().any(|f| f == "audio") {
777 return ProjectType::Music;
778 }
779 if features.iter().any(|f| f == "text") && !features.iter().any(|f| f == "downloads") {
780 return ProjectType::Blog;
781 }
782 if features.iter().any(|f| f == "downloads") {
783 return ProjectType::Software;
784 }
785 ProjectType::General
786 }
787
788 /// Which item types a feature unlocks.
789 pub fn allowed_item_types(&self) -> &'static [ItemType] {
790 match self {
791 Self::Audio => &[ItemType::Audio, ItemType::Sample, ItemType::Preset],
792 Self::Downloads => &[
793 ItemType::Digital,
794 ItemType::Plugin,
795 ItemType::Template,
796 ItemType::Course,
797 ItemType::Image,
798 ItemType::Video,
799 ],
800 Self::Text => &[ItemType::Text],
801 // Non-content features don't gate item types
802 Self::Blog
803 | Self::Subscriptions
804 | Self::LicenseKeys
805 | Self::SourceCode
806 | Self::CloudSync => &[],
807 }
808 }
809
810 /// Compute the set of item types allowed by a project's feature list.
811 /// If no content features are enabled, all types are allowed (permissive default).
812 pub fn allowed_item_type_cards(
813 features: &[String],
814 ) -> Vec<(&'static str, &'static str, &'static str)> {
815 let allowed: std::collections::HashSet<ItemType> = features
816 .iter()
817 .filter_map(|f| f.parse::<ProjectFeature>().ok())
818 .flat_map(|f| f.allowed_item_types().iter().copied())
819 .collect();
820
821 // If no content features enabled, show all types (backwards compat)
822 if allowed.is_empty() {
823 return Self::all_item_type_cards().to_vec();
824 }
825
826 Self::all_item_type_cards()
827 .iter()
828 .filter(|(value, _, _)| {
829 value
830 .parse::<ItemType>()
831 .is_ok_and(|t| t == ItemType::Bundle || allowed.contains(&t))
832 })
833 .copied()
834 .collect()
835 }
836
837 /// All item type cards: (value, label, description) tuples for form rendering.
838 pub fn all_item_type_cards() -> &'static [(&'static str, &'static str, &'static str)] {
839 &[
840 ("audio", "Audio", "Podcast, music, sound effects"),
841 ("text", "Text", "Articles, posts, essays, guides"),
842 ("digital", "Digital Download", "Files, archives, documents"),
843 ("video", "Video", "Tutorials, films, recordings"),
844 ("course", "Course", "Multi-part lessons, curricula"),
845 ("plugin", "Plugin", "Software extensions, add-ons"),
846 ("sample", "Sample Pack", "Audio samples, loops, one-shots"),
847 ("preset", "Preset Pack", "Synth presets, effect chains"),
848 ("template", "Template", "Design templates, starter kits"),
849 ("image", "Image", "Photos, artwork, graphics"),
850 ("bundle", "Bundle", "Collection of other items"),
851 ]
852 }
853
854 /// Item type cards filtered to one per distinct wizard behavior group.
855 ///
856 /// The wizard only needs a type selector when the allowed types produce
857 /// different content-step UIs (text editor vs audio upload vs file upload).
858 /// Returns one card per group, using the first allowed type as the value.
859 /// If all types share one group, returns a single card (caller should skip
860 /// the type step entirely).
861 pub fn wizard_type_cards(
862 features: &[String],
863 ) -> Vec<(&'static str, &'static str, &'static str)> {
864 let allowed = Self::allowed_item_type_cards(features);
865 let mut seen_groups = std::collections::HashSet::new();
866 let mut cards = Vec::new();
867
868 for (value, _, _) in &allowed {
869 let Ok(item_type) = value.parse::<ItemType>() else {
870 continue;
871 };
872 let group = item_type.wizard_group();
873 if seen_groups.insert(group) {
874 let (label, desc) = match group {
875 "text" => ("Text", "Write in the editor"),
876 "audio" => ("Audio", "Upload audio files"),
877 "video" => ("Video", "Upload video files"),
878 "bundle" => ("Bundle", "Collection of other items"),
879 _ => ("File", "Upload any file"),
880 };
881 cards.push((*value, label, desc));
882 }
883 }
884
885 cards
886 }
887 }
888
889 // ── Projects ──
890
891 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
892 #[serde(rename_all = "lowercase")]
893 pub enum ProjectType {
894 Blog,
895 Book,
896 Podcast,
897 Course,
898 Music,
899 Software,
900 Art,
901 Writing,
902 #[default]
903 General,
904 }
905
906 impl_str_enum!(ProjectType {
907 Blog => "blog",
908 Book => "book",
909 Podcast => "podcast",
910 Course => "course",
911 Music => "music",
912 Software => "software",
913 Art => "art",
914 Writing => "writing",
915 General => "general",
916 });
917
918 impl ProjectType {
919 /// Human-readable label for display.
920 pub fn label(&self) -> &'static str {
921 match self {
922 Self::Blog => "Blog",
923 Self::Book => "Book",
924 Self::Podcast => "Podcast",
925 Self::Course => "Course",
926 Self::Music => "Music",
927 Self::Software => "Software",
928 Self::Art => "Art",
929 Self::Writing => "Writing",
930 Self::General => "General",
931 }
932 }
933
934 /// All valid project types as (value, label) pairs for form rendering.
935 pub fn all() -> &'static [(&'static str, &'static str)] {
936 &[
937 ("blog", "Blog"),
938 ("book", "Book"),
939 ("podcast", "Podcast"),
940 ("course", "Course"),
941 ("music", "Music"),
942 ("software", "Software"),
943 ("art", "Art"),
944 ("writing", "Writing"),
945 ("general", "General"),
946 ]
947 }
948 }
949
950 // ── Build Pipeline ──
951
952 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
953 #[serde(rename_all = "snake_case")]
954 pub enum BuildStatus {
955 Pending,
956 Running,
957 Succeeded,
958 Failed,
959 Cancelled,
960 }
961
962 impl_str_enum!(BuildStatus {
963 Pending => "pending",
964 Running => "running",
965 Succeeded => "succeeded",
966 Failed => "failed",
967 Cancelled => "cancelled",
968 });
969
970 // ── Project Pricing ──
971
972 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
973 #[serde(rename_all = "snake_case")]
974 pub enum PricingKind {
975 #[default]
976 Free,
977 BuyOnce,
978 Pwyw,
979 Subscription,
980 }
981
982 impl_str_enum!(PricingKind {
983 Free => "free",
984 BuyOnce => "buy_once",
985 Pwyw => "pwyw",
986 Subscription => "subscription",
987 });
988
989 // ── Mailing Lists ──
990
991 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
992 #[serde(rename_all = "lowercase")]
993 pub enum MailingListType {
994 Content,
995 Devlog,
996 Patches,
997 }
998
999 impl_str_enum!(MailingListType {
1000 Content => "content",
1001 Devlog => "devlog",
1002 Patches => "patches",
1003 });
1004
1005 // ── Import System ──
1006
1007 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1008 #[serde(rename_all = "snake_case")]
1009 pub enum ImportSource {
1010 GenericCsv,
1011 Substack,
1012 Ghost,
1013 Gumroad,
1014 Bandcamp,
1015 LemonSqueezy,
1016 Patreon,
1017 }
1018
1019 impl_str_enum!(ImportSource {
1020 GenericCsv => "generic_csv",
1021 Substack => "substack",
1022 Ghost => "ghost",
1023 Gumroad => "gumroad",
1024 Bandcamp => "bandcamp",
1025 LemonSqueezy => "lemon_squeezy",
1026 Patreon => "patreon",
1027 });
1028
1029 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1030 #[serde(rename_all = "lowercase")]
1031 pub enum ImportJobStatus {
1032 Pending,
1033 Processing,
1034 Completed,
1035 Failed,
1036 }
1037
1038 impl_str_enum!(ImportJobStatus {
1039 Pending => "pending",
1040 Processing => "processing",
1041 Completed => "completed",
1042 Failed => "failed",
1043 });
1044
1045 // Moderation action types
1046
1047 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1048 #[serde(rename_all = "snake_case")]
1049 pub enum ModerationActionType {
1050 Warning,
1051 Suspension,
1052 Termination,
1053 ContentRemoval,
1054 }
1055
1056 impl_str_enum!(ModerationActionType {
1057 Warning => "warning",
1058 Suspension => "suspension",
1059 Termination => "termination",
1060 ContentRemoval => "content_removal",
1061 });
1062
1063 // Checkout types (Stripe metadata)
1064
1065 /// Discriminator for checkout session types stored in Stripe metadata.
1066 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1067 #[serde(rename_all = "snake_case")]
1068 pub enum CheckoutType {
1069 Guest,
1070 Subscription,
1071 Tip,
1072 FanPlus,
1073 CreatorTier,
1074 Cart,
1075 SynckitAppSub,
1076 }
1077
1078 impl_str_enum!(CheckoutType {
1079 Guest => "guest",
1080 Subscription => "subscription",
1081 Tip => "tip",
1082 FanPlus => "fan_plus",
1083 CreatorTier => "creator_tier",
1084 Cart => "cart",
1085 SynckitAppSub => "synckit_app_sub",
1086 });
1087
1088 impl ModerationActionType {
1089 pub fn label(&self) -> &'static str {
1090 match self {
1091 Self::Warning => "Warning",
1092 Self::Suspension => "Suspension",
1093 Self::Termination => "Termination",
1094 Self::ContentRemoval => "Content Removal",
1095 }
1096 }
1097 }
1098
1099 #[cfg(test)]
1100 mod tests {
1101 use super::*;
1102
1103 #[test]
1104 fn discount_type_round_trip() {
1105 assert_eq!(DiscountType::Percentage.to_string(), "percentage");
1106 assert_eq!(
1107 "fixed".parse::<DiscountType>().unwrap(),
1108 DiscountType::Fixed
1109 );
1110 assert!("bogus".parse::<DiscountType>().is_err());
1111 }
1112
1113 #[test]
1114 fn waitlist_status_round_trip() {
1115 assert_eq!(WaitlistStatus::Pending.to_string(), "pending");
1116 assert_eq!(
1117 "approved".parse::<WaitlistStatus>().unwrap(),
1118 WaitlistStatus::Approved
1119 );
1120 }
1121
1122 #[test]
1123 fn selection_method_round_trip() {
1124 assert_eq!(SelectionMethod::HandPicked.to_string(), "hand_picked");
1125 assert_eq!(
1126 "lottery".parse::<SelectionMethod>().unwrap(),
1127 SelectionMethod::Lottery
1128 );
1129 assert_eq!(SelectionMethod::Invited.to_string(), "invited");
1130 assert_eq!(
1131 "invited".parse::<SelectionMethod>().unwrap(),
1132 SelectionMethod::Invited
1133 );
1134 }
1135
1136 #[test]
1137 fn transaction_status_round_trip() {
1138 assert_eq!(TransactionStatus::Completed.to_string(), "completed");
1139 assert_eq!(
1140 "refunded".parse::<TransactionStatus>().unwrap(),
1141 TransactionStatus::Refunded
1142 );
1143 }
1144
1145 #[test]
1146 fn follow_target_type_round_trip() {
1147 assert_eq!(FollowTargetType::User.to_string(), "user");
1148 assert_eq!(
1149 "tag".parse::<FollowTargetType>().unwrap(),
1150 FollowTargetType::Tag
1151 );
1152 }
1153
1154 #[test]
1155 fn subscription_status_round_trip() {
1156 assert_eq!(SubscriptionStatus::PastDue.to_string(), "past_due");
1157 assert_eq!(
1158 "canceled".parse::<SubscriptionStatus>().unwrap(),
1159 SubscriptionStatus::Canceled
1160 );
1161 assert_eq!(SubscriptionStatus::Trialing.to_string(), "trialing");
1162 assert_eq!(
1163 "trialing".parse::<SubscriptionStatus>().unwrap(),
1164 SubscriptionStatus::Trialing
1165 );
1166 assert_eq!(SubscriptionStatus::Incomplete.to_string(), "incomplete");
1167 assert_eq!(
1168 "incomplete".parse::<SubscriptionStatus>().unwrap(),
1169 SubscriptionStatus::Incomplete
1170 );
1171 assert_eq!(
1172 SubscriptionStatus::IncompleteExpired.to_string(),
1173 "incomplete_expired"
1174 );
1175 assert_eq!(
1176 "incomplete_expired".parse::<SubscriptionStatus>().unwrap(),
1177 SubscriptionStatus::IncompleteExpired
1178 );
1179 }
1180
1181 #[test]
1182 fn sync_operation_round_trip() {
1183 assert_eq!(SyncOperation::Insert.to_string(), "INSERT");
1184 assert_eq!(
1185 "DELETE".parse::<SyncOperation>().unwrap(),
1186 SyncOperation::Delete
1187 );
1188 }
1189
1190 #[test]
1191 fn sync_platform_round_trip() {
1192 assert_eq!(SyncPlatform::Macos.to_string(), "macos");
1193 assert_eq!("web".parse::<SyncPlatform>().unwrap(), SyncPlatform::Web);
1194 }
1195
1196 #[test]
1197 fn item_type_round_trip() {
1198 assert_eq!(ItemType::Audio.to_string(), "audio");
1199 assert_eq!("plugin".parse::<ItemType>().unwrap(), ItemType::Plugin);
1200 assert_eq!(ItemType::Bundle.to_string(), "bundle");
1201 assert_eq!("bundle".parse::<ItemType>().unwrap(), ItemType::Bundle);
1202 }
1203
1204 #[test]
1205 fn insertion_position_round_trip() {
1206 assert_eq!(InsertionPosition::PreRoll.to_string(), "pre_roll");
1207 assert_eq!(
1208 "mid_roll".parse::<InsertionPosition>().unwrap(),
1209 InsertionPosition::MidRoll
1210 );
1211 assert_eq!(
1212 "post_roll".parse::<InsertionPosition>().unwrap(),
1213 InsertionPosition::PostRoll
1214 );
1215 assert!("invalid".parse::<InsertionPosition>().is_err());
1216 }
1217
1218 #[test]
1219 fn item_type_label() {
1220 assert_eq!(ItemType::Audio.label(), "Audio");
1221 assert_eq!(ItemType::Plugin.label(), "Plugin");
1222 assert_eq!(ItemType::Template.label(), "Template");
1223 }
1224
1225 #[test]
1226 fn appeal_decision_round_trip() {
1227 assert_eq!(AppealDecision::Approved.to_string(), "approved");
1228 assert_eq!(
1229 "denied".parse::<AppealDecision>().unwrap(),
1230 AppealDecision::Denied
1231 );
1232 assert!("bogus".parse::<AppealDecision>().is_err());
1233 }
1234
1235 #[test]
1236 fn discover_sort_round_trip() {
1237 assert_eq!(DiscoverSort::Newest.to_string(), "newest");
1238 assert_eq!(
1239 "most_sold".parse::<DiscoverSort>().unwrap(),
1240 DiscoverSort::MostSold
1241 );
1242 assert_eq!(
1243 "price_asc".parse::<DiscoverSort>().unwrap(),
1244 DiscoverSort::PriceAsc
1245 );
1246 assert_eq!(
1247 "price_desc".parse::<DiscoverSort>().unwrap(),
1248 DiscoverSort::PriceDesc
1249 );
1250 assert!("invalid".parse::<DiscoverSort>().is_err());
1251 }
1252
1253 #[test]
1254 fn file_scan_status_round_trip() {
1255 assert_eq!(FileScanStatus::Clean.to_string(), "clean");
1256 assert_eq!(FileScanStatus::Pending.to_string(), "pending");
1257 assert_eq!(FileScanStatus::Scanning.to_string(), "scanning");
1258 assert_eq!(
1259 "pending".parse::<FileScanStatus>().unwrap(),
1260 FileScanStatus::Pending
1261 );
1262 assert_eq!(
1263 "scanning".parse::<FileScanStatus>().unwrap(),
1264 FileScanStatus::Scanning
1265 );
1266 assert_eq!(
1267 "held_for_review".parse::<FileScanStatus>().unwrap(),
1268 FileScanStatus::HeldForReview
1269 );
1270 assert_eq!(FileScanStatus::HeldForReview.to_string(), "held_for_review");
1271 assert_eq!(
1272 "quarantined".parse::<FileScanStatus>().unwrap(),
1273 FileScanStatus::Quarantined
1274 );
1275 assert!("bogus".parse::<FileScanStatus>().is_err());
1276 }
1277
1278 #[test]
1279 fn code_purpose_round_trip() {
1280 assert_eq!(CodePurpose::Discount.to_string(), "discount");
1281 assert_eq!(
1282 "free_access".parse::<CodePurpose>().unwrap(),
1283 CodePurpose::FreeAccess
1284 );
1285 assert_eq!(
1286 "free_trial".parse::<CodePurpose>().unwrap(),
1287 CodePurpose::FreeTrial
1288 );
1289 assert!("bogus".parse::<CodePurpose>().is_err());
1290 }
1291
1292 #[test]
1293 fn issue_status_round_trip() {
1294 assert_eq!(IssueStatus::Open.to_string(), "open");
1295 assert_eq!(
1296 "closed".parse::<IssueStatus>().unwrap(),
1297 IssueStatus::Closed
1298 );
1299 assert!("bogus".parse::<IssueStatus>().is_err());
1300 }
1301
1302 #[test]
1303 fn report_target_type_round_trip() {
1304 assert_eq!(ReportTargetType::Project.to_string(), "project");
1305 assert_eq!(
1306 "item".parse::<ReportTargetType>().unwrap(),
1307 ReportTargetType::Item
1308 );
1309 assert!("bogus".parse::<ReportTargetType>().is_err());
1310 }
1311
1312 #[test]
1313 fn report_type_round_trip() {
1314 assert_eq!(ReportType::Mislabeled.to_string(), "mislabeled");
1315 assert_eq!("spam".parse::<ReportType>().unwrap(), ReportType::Spam);
1316 assert_eq!("abuse".parse::<ReportType>().unwrap(), ReportType::Abuse);
1317 assert_eq!(
1318 "infringement".parse::<ReportType>().unwrap(),
1319 ReportType::Infringement
1320 );
1321 assert_eq!("other".parse::<ReportType>().unwrap(), ReportType::Other);
1322 assert!("bogus".parse::<ReportType>().is_err());
1323 }
1324
1325 #[test]
1326 fn report_status_round_trip() {
1327 assert_eq!(ReportStatus::Open.to_string(), "open");
1328 assert_eq!(
1329 "resolved".parse::<ReportStatus>().unwrap(),
1330 ReportStatus::Resolved
1331 );
1332 assert_eq!(
1333 "dismissed".parse::<ReportStatus>().unwrap(),
1334 ReportStatus::Dismissed
1335 );
1336 assert!("bogus".parse::<ReportStatus>().is_err());
1337 }
1338
1339 #[test]
1340 fn creator_tier_round_trip() {
1341 assert_eq!(CreatorTier::Basic.to_string(), "basic");
1342 assert_eq!(
1343 "small_files".parse::<CreatorTier>().unwrap(),
1344 CreatorTier::SmallFiles
1345 );
1346 assert_eq!(
1347 "big_files".parse::<CreatorTier>().unwrap(),
1348 CreatorTier::BigFiles
1349 );
1350 assert_eq!(
1351 "everything".parse::<CreatorTier>().unwrap(),
1352 CreatorTier::Everything
1353 );
1354 assert!("bogus".parse::<CreatorTier>().is_err());
1355 }
1356
1357 #[test]
1358 fn creator_tier_label_and_price() {
1359 crate::tier_prices::TierPrices::install_test_default();
1360 assert_eq!(CreatorTier::Basic.label(), "Basic");
1361 assert_eq!(CreatorTier::SmallFiles.label(), "Small Files");
1362 // Prices come from assumptions.toml. Assert invariants, founder = std/2,
1363 // monotone across tiers, not literal cents, so a future toml edit doesn't
1364 // break this test.
1365 let tiers = [
1366 CreatorTier::Basic,
1367 CreatorTier::SmallFiles,
1368 CreatorTier::BigFiles,
1369 CreatorTier::Everything,
1370 ];
1371 for pair in tiers.windows(2) {
1372 assert!(
1373 pair[0].price_cents() < pair[1].price_cents(),
1374 "{:?} price_cents ({}) should be < {:?} ({})",
1375 pair[0],
1376 pair[0].price_cents(),
1377 pair[1],
1378 pair[1].price_cents(),
1379 );
1380 }
1381 assert!(CreatorTier::Basic.price_cents() > 0);
1382 }
1383
1384 #[test]
1385 fn creator_tier_file_limits() {
1386 crate::tier_prices::TierPrices::install_test_default();
1387 // File caps monotone across tiers; Basic ≤ SmallFiles ≤ BigFiles == Everything.
1388 let tiers = [
1389 CreatorTier::Basic,
1390 CreatorTier::SmallFiles,
1391 CreatorTier::BigFiles,
1392 CreatorTier::Everything,
1393 ];
1394 for pair in tiers.windows(2) {
1395 assert!(
1396 pair[0].max_file_bytes() <= pair[1].max_file_bytes(),
1397 "{:?} max_file_bytes ({}) should be <= {:?} ({})",
1398 pair[0],
1399 pair[0].max_file_bytes(),
1400 pair[1],
1401 pair[1].max_file_bytes(),
1402 );
1403 }
1404 assert!(CreatorTier::Basic.max_file_bytes() > 0);
1405 }
1406
1407 #[test]
1408 fn creator_tier_storage_limits() {
1409 crate::tier_prices::TierPrices::install_test_default();
1410 let tiers = [
1411 CreatorTier::Basic,
1412 CreatorTier::SmallFiles,
1413 CreatorTier::BigFiles,
1414 CreatorTier::Everything,
1415 ];
1416 for pair in tiers.windows(2) {
1417 assert!(
1418 pair[0].max_storage_bytes() <= pair[1].max_storage_bytes(),
1419 "{:?} max_storage_bytes ({}) should be <= {:?} ({})",
1420 pair[0],
1421 pair[0].max_storage_bytes(),
1422 pair[1],
1423 pair[1].max_storage_bytes(),
1424 );
1425 }
1426 assert!(CreatorTier::Basic.max_storage_bytes() > 0);
1427 // Every tier's per-file cap must fit in its total storage cap or uploads
1428 // are impossible. Catches an accidental toml edit that shrinks a total
1429 // below the per-file limit.
1430 for &tier in &tiers {
1431 assert!(
1432 tier.max_file_bytes() <= tier.max_storage_bytes(),
1433 "{tier:?}: file cap ({}) exceeds storage cap ({})",
1434 tier.max_file_bytes(),
1435 tier.max_storage_bytes(),
1436 );
1437 }
1438 }
1439
1440 #[test]
1441 fn creator_tier_allows_file_uploads() {
1442 assert!(!CreatorTier::Basic.allows_file_uploads());
1443 assert!(CreatorTier::SmallFiles.allows_file_uploads());
1444 assert!(CreatorTier::BigFiles.allows_file_uploads());
1445 assert!(CreatorTier::Everything.allows_file_uploads());
1446 }
1447
1448 #[test]
1449 fn creator_tier_features_track_live_capabilities() {
1450 assert!(CreatorTier::Basic.features().is_empty());
1451 assert_eq!(CreatorTier::SmallFiles.features(), &["file_uploads"]);
1452 assert_eq!(
1453 CreatorTier::BigFiles.features(),
1454 &["file_uploads", "large_files"]
1455 );
1456 assert_eq!(
1457 CreatorTier::Everything.features(),
1458 &["file_uploads", "large_files"]
1459 );
1460 }
1461
1462 #[test]
1463 fn project_feature_round_trip() {
1464 assert_eq!(ProjectFeature::Audio.to_string(), "audio");
1465 assert_eq!(
1466 "downloads".parse::<ProjectFeature>().unwrap(),
1467 ProjectFeature::Downloads
1468 );
1469 assert_eq!(
1470 "license_keys".parse::<ProjectFeature>().unwrap(),
1471 ProjectFeature::LicenseKeys
1472 );
1473 assert_eq!(
1474 "source_code".parse::<ProjectFeature>().unwrap(),
1475 ProjectFeature::SourceCode
1476 );
1477 assert!("bogus".parse::<ProjectFeature>().is_err());
1478 }
1479
1480 #[test]
1481 fn project_feature_label_and_description() {
1482 assert_eq!(ProjectFeature::Audio.label(), "Audio");
1483 assert_eq!(ProjectFeature::LicenseKeys.label(), "License Keys");
1484 assert!(!ProjectFeature::Audio.description().is_empty());
1485 }
1486
1487 #[test]
1488 fn project_feature_all() {
1489 let all = ProjectFeature::all();
1490 assert_eq!(all.len(), 8);
1491 assert_eq!(all[0].0, "audio");
1492 assert_eq!(all[7].0, "cloud_sync");
1493 }
1494
1495 #[test]
1496 fn project_feature_allowed_item_types_audio() {
1497 let types = ProjectFeature::Audio.allowed_item_types();
1498 assert!(types.contains(&ItemType::Audio));
1499 assert!(types.contains(&ItemType::Sample));
1500 assert!(types.contains(&ItemType::Preset));
1501 assert!(!types.contains(&ItemType::Text));
1502 }
1503
1504 #[test]
1505 fn project_feature_allowed_item_types_downloads() {
1506 let types = ProjectFeature::Downloads.allowed_item_types();
1507 assert!(types.contains(&ItemType::Digital));
1508 assert!(types.contains(&ItemType::Plugin));
1509 assert!(types.contains(&ItemType::Video));
1510 assert!(!types.contains(&ItemType::Audio));
1511 }
1512
1513 #[test]
1514 fn project_feature_allowed_item_types_text() {
1515 let types = ProjectFeature::Text.allowed_item_types();
1516 assert!(types.contains(&ItemType::Text));
1517 assert_eq!(types.len(), 1);
1518 }
1519
1520 #[test]
1521 fn project_feature_allowed_item_types_non_content() {
1522 assert!(ProjectFeature::Blog.allowed_item_types().is_empty());
1523 assert!(
1524 ProjectFeature::Subscriptions
1525 .allowed_item_types()
1526 .is_empty()
1527 );
1528 assert!(ProjectFeature::LicenseKeys.allowed_item_types().is_empty());
1529 assert!(ProjectFeature::SourceCode.allowed_item_types().is_empty());
1530 assert!(ProjectFeature::CloudSync.allowed_item_types().is_empty());
1531 }
1532
1533 #[test]
1534 fn project_feature_allowed_cards_filtered() {
1535 let cards = ProjectFeature::allowed_item_type_cards(&["audio".into()]);
1536 let values: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
1537 assert!(values.contains(&"audio"));
1538 assert!(values.contains(&"sample"));
1539 assert!(values.contains(&"preset"));
1540 assert!(values.contains(&"bundle")); // Bundle always included
1541 assert!(!values.contains(&"text"));
1542 assert!(!values.contains(&"digital"));
1543 }
1544
1545 #[test]
1546 fn project_feature_allowed_cards_combined() {
1547 let cards = ProjectFeature::allowed_item_type_cards(&["audio".into(), "text".into()]);
1548 let values: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
1549 assert!(values.contains(&"audio"));
1550 assert!(values.contains(&"text"));
1551 assert!(values.contains(&"bundle")); // Bundle always included
1552 assert!(!values.contains(&"digital"));
1553 }
1554
1555 #[test]
1556 fn project_feature_allowed_cards_empty_features_shows_all() {
1557 let cards = ProjectFeature::allowed_item_type_cards(&[]);
1558 assert_eq!(cards.len(), 11); // 10 content types + bundle
1559 }
1560
1561 #[test]
1562 fn project_feature_allowed_cards_non_content_features_shows_all() {
1563 let cards =
1564 ProjectFeature::allowed_item_type_cards(&["blog".into(), "subscriptions".into()]);
1565 // Blog and subscriptions don't gate item types, so all should be shown
1566 assert_eq!(cards.len(), 11); // 10 content types + bundle
1567 }
1568
1569 #[test]
1570 fn project_feature_derive_type() {
1571 assert_eq!(
1572 ProjectFeature::derive_project_type(&["audio".into(), "blog".into()]),
1573 ProjectType::Music,
1574 );
1575 assert_eq!(
1576 ProjectFeature::derive_project_type(&["text".into()]),
1577 ProjectType::Blog,
1578 );
1579 assert_eq!(
1580 ProjectFeature::derive_project_type(&["downloads".into(), "text".into()]),
1581 ProjectType::Software,
1582 );
1583 assert_eq!(
1584 ProjectFeature::derive_project_type(&["subscriptions".into()]),
1585 ProjectType::General,
1586 );
1587 }
1588
1589 #[test]
1590 fn project_type_round_trip() {
1591 assert_eq!(ProjectType::Blog.to_string(), "blog");
1592 assert_eq!(
1593 "software".parse::<ProjectType>().unwrap(),
1594 ProjectType::Software
1595 );
1596 assert_eq!(
1597 "general".parse::<ProjectType>().unwrap(),
1598 ProjectType::General
1599 );
1600 assert_eq!(ProjectType::default(), ProjectType::General);
1601 assert!("bogus".parse::<ProjectType>().is_err());
1602 }
1603
1604 #[test]
1605 fn project_type_label() {
1606 assert_eq!(ProjectType::Blog.label(), "Blog");
1607 assert_eq!(ProjectType::Software.label(), "Software");
1608 assert_eq!(ProjectType::General.label(), "General");
1609 }
1610
1611 #[test]
1612 fn project_type_all() {
1613 let all = ProjectType::all();
1614 assert_eq!(all.len(), 9);
1615 assert_eq!(all[0], ("blog", "Blog"));
1616 assert_eq!(all[8], ("general", "General"));
1617 }
1618
1619 #[test]
1620 fn build_status_round_trip() {
1621 assert_eq!(BuildStatus::Pending.to_string(), "pending");
1622 assert_eq!(
1623 "running".parse::<BuildStatus>().unwrap(),
1624 BuildStatus::Running
1625 );
1626 assert_eq!(
1627 "succeeded".parse::<BuildStatus>().unwrap(),
1628 BuildStatus::Succeeded
1629 );
1630 assert_eq!(
1631 "failed".parse::<BuildStatus>().unwrap(),
1632 BuildStatus::Failed
1633 );
1634 assert_eq!(
1635 "cancelled".parse::<BuildStatus>().unwrap(),
1636 BuildStatus::Cancelled
1637 );
1638 assert!("bogus".parse::<BuildStatus>().is_err());
1639 }
1640
1641 #[test]
1642 fn serde_json_round_trip() {
1643 let dt = DiscountType::Percentage;
1644 let json = serde_json::to_string(&dt).unwrap();
1645 assert_eq!(json, "\"percentage\"");
1646 let back: DiscountType = serde_json::from_str(&json).unwrap();
1647 assert_eq!(back, dt);
1648 }
1649
1650 #[test]
1651 fn pricing_kind_round_trip() {
1652 assert_eq!(PricingKind::Free.to_string(), "free");
1653 assert_eq!(
1654 "buy_once".parse::<PricingKind>().unwrap(),
1655 PricingKind::BuyOnce
1656 );
1657 assert_eq!("pwyw".parse::<PricingKind>().unwrap(), PricingKind::Pwyw);
1658 assert_eq!(
1659 "subscription".parse::<PricingKind>().unwrap(),
1660 PricingKind::Subscription
1661 );
1662 assert_eq!(PricingKind::default(), PricingKind::Free);
1663 assert!("bogus".parse::<PricingKind>().is_err());
1664 }
1665
1666 #[test]
1667 fn mailing_list_type_round_trip() {
1668 assert_eq!(MailingListType::Content.to_string(), "content");
1669 assert_eq!(
1670 "devlog".parse::<MailingListType>().unwrap(),
1671 MailingListType::Devlog
1672 );
1673 assert_eq!(
1674 "patches".parse::<MailingListType>().unwrap(),
1675 MailingListType::Patches
1676 );
1677 assert!("bogus".parse::<MailingListType>().is_err());
1678 }
1679
1680 #[test]
1681 fn serde_json_subscription_status() {
1682 let s = SubscriptionStatus::PastDue;
1683 let json = serde_json::to_string(&s).unwrap();
1684 assert_eq!(json, "\"past_due\"");
1685 let back: SubscriptionStatus = serde_json::from_str(&json).unwrap();
1686 assert_eq!(back, s);
1687
1688 let t = SubscriptionStatus::Trialing;
1689 let json = serde_json::to_string(&t).unwrap();
1690 assert_eq!(json, "\"trialing\"");
1691 let back: SubscriptionStatus = serde_json::from_str(&json).unwrap();
1692 assert_eq!(back, t);
1693 }
1694
1695 // ── ItemType::wizard_group ──
1696
1697 #[test]
1698 fn wizard_group_text() {
1699 assert_eq!(ItemType::Text.wizard_group(), "text");
1700 }
1701
1702 #[test]
1703 fn wizard_group_audio() {
1704 assert_eq!(ItemType::Audio.wizard_group(), "audio");
1705 }
1706
1707 #[test]
1708 fn wizard_group_video() {
1709 assert_eq!(ItemType::Video.wizard_group(), "video");
1710 }
1711
1712 #[test]
1713 fn wizard_group_file_types() {
1714 for t in [
1715 ItemType::Digital,
1716 ItemType::Course,
1717 ItemType::Plugin,
1718 ItemType::Sample,
1719 ItemType::Preset,
1720 ItemType::Template,
1721 ItemType::Image,
1722 ] {
1723 assert_eq!(t.wizard_group(), "file", "{t:?} should be in file group");
1724 }
1725 }
1726
1727 #[test]
1728 fn wizard_group_bundle() {
1729 assert_eq!(ItemType::Bundle.wizard_group(), "bundle");
1730 }
1731
1732 // ── ProjectFeature::wizard_type_cards ──
1733
1734 #[test]
1735 fn wizard_cards_text_only_two_groups() {
1736 // Text + bundle (bundle always included)
1737 let cards = ProjectFeature::wizard_type_cards(&["text".into()]);
1738 assert_eq!(cards.len(), 2);
1739 let groups: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
1740 assert!(groups.contains(&"text"));
1741 assert!(groups.contains(&"bundle"));
1742 }
1743
1744 #[test]
1745 fn wizard_cards_downloads_three_groups() {
1746 // Download types split into "file" + "video" groups + bundle → 3 cards
1747 let cards = ProjectFeature::wizard_type_cards(&["downloads".into()]);
1748 assert_eq!(cards.len(), 3);
1749 let groups: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
1750 assert!(groups.contains(&"digital")); // first type in file group
1751 assert!(groups.contains(&"video")); // video group
1752 assert!(groups.contains(&"bundle"));
1753 }
1754
1755 #[test]
1756 fn wizard_cards_audio_feature_three_groups() {
1757 // Audio feature allows audio (audio group) + sample, preset (file group) + bundle
1758 let cards = ProjectFeature::wizard_type_cards(&["audio".into()]);
1759 assert_eq!(cards.len(), 3);
1760 let groups: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
1761 assert!(groups.contains(&"audio"));
1762 assert!(groups.contains(&"sample")); // first file-group type
1763 assert!(groups.contains(&"bundle"));
1764 }
1765
1766 #[test]
1767 fn wizard_cards_text_and_audio_four_groups() {
1768 let cards = ProjectFeature::wizard_type_cards(&["text".into(), "audio".into()]);
1769 assert_eq!(cards.len(), 4); // text, audio, file, bundle
1770 }
1771
1772 #[test]
1773 fn wizard_cards_empty_features_all_five_groups() {
1774 // No content features → all types → 5 wizard groups (text, audio, video, file, bundle)
1775 let cards = ProjectFeature::wizard_type_cards(&[]);
1776 assert_eq!(cards.len(), 5);
1777 }
1778
1779 #[test]
1780 fn ai_tier_round_trip() {
1781 assert_eq!(AiTier::Handmade.to_string(), "handmade");
1782 assert_eq!("assisted".parse::<AiTier>().unwrap(), AiTier::Assisted);
1783 assert_eq!("generated".parse::<AiTier>().unwrap(), AiTier::Generated);
1784 assert!("bogus".parse::<AiTier>().is_err());
1785 }
1786
1787 #[test]
1788 fn ai_tier_label() {
1789 assert_eq!(AiTier::Handmade.label(), "Handmade");
1790 assert_eq!(AiTier::Assisted.label(), "Assisted");
1791 assert_eq!(AiTier::Generated.label(), "Generated");
1792 }
1793
1794 #[test]
1795 fn import_source_round_trip() {
1796 assert_eq!(ImportSource::GenericCsv.to_string(), "generic_csv");
1797 assert_eq!(
1798 "substack".parse::<ImportSource>().unwrap(),
1799 ImportSource::Substack
1800 );
1801 assert_eq!(
1802 "ghost".parse::<ImportSource>().unwrap(),
1803 ImportSource::Ghost
1804 );
1805 assert_eq!(
1806 "gumroad".parse::<ImportSource>().unwrap(),
1807 ImportSource::Gumroad
1808 );
1809 assert_eq!(
1810 "bandcamp".parse::<ImportSource>().unwrap(),
1811 ImportSource::Bandcamp
1812 );
1813 assert_eq!(
1814 "lemon_squeezy".parse::<ImportSource>().unwrap(),
1815 ImportSource::LemonSqueezy
1816 );
1817 assert_eq!(
1818 "patreon".parse::<ImportSource>().unwrap(),
1819 ImportSource::Patreon
1820 );
1821 assert!("bogus".parse::<ImportSource>().is_err());
1822 }
1823
1824 #[test]
1825 fn import_job_status_round_trip() {
1826 assert_eq!(ImportJobStatus::Pending.to_string(), "pending");
1827 assert_eq!(
1828 "processing".parse::<ImportJobStatus>().unwrap(),
1829 ImportJobStatus::Processing
1830 );
1831 assert_eq!(
1832 "completed".parse::<ImportJobStatus>().unwrap(),
1833 ImportJobStatus::Completed
1834 );
1835 assert_eq!(
1836 "failed".parse::<ImportJobStatus>().unwrap(),
1837 ImportJobStatus::Failed
1838 );
1839 assert!("bogus".parse::<ImportJobStatus>().is_err());
1840 }
1841
1842 #[test]
1843 fn checkout_type_round_trip() {
1844 assert_eq!(CheckoutType::Guest.to_string(), "guest");
1845 assert_eq!(CheckoutType::Subscription.to_string(), "subscription");
1846 assert_eq!(CheckoutType::Tip.to_string(), "tip");
1847 assert_eq!(CheckoutType::FanPlus.to_string(), "fan_plus");
1848 assert_eq!(CheckoutType::CreatorTier.to_string(), "creator_tier");
1849 assert_eq!(
1850 "guest".parse::<CheckoutType>().unwrap(),
1851 CheckoutType::Guest
1852 );
1853 assert_eq!(
1854 "fan_plus".parse::<CheckoutType>().unwrap(),
1855 CheckoutType::FanPlus
1856 );
1857 assert!("bogus".parse::<CheckoutType>().is_err());
1858 }
1859
1860 #[test]
1861 fn moderation_action_type_round_trip() {
1862 assert_eq!(ModerationActionType::Warning.to_string(), "warning");
1863 assert_eq!(ModerationActionType::Suspension.to_string(), "suspension");
1864 assert_eq!(ModerationActionType::Termination.to_string(), "termination");
1865 assert_eq!(
1866 ModerationActionType::ContentRemoval.to_string(),
1867 "content_removal"
1868 );
1869 assert_eq!(
1870 "warning".parse::<ModerationActionType>().unwrap(),
1871 ModerationActionType::Warning
1872 );
1873 assert_eq!(
1874 "content_removal".parse::<ModerationActionType>().unwrap(),
1875 ModerationActionType::ContentRemoval
1876 );
1877 assert!("bogus".parse::<ModerationActionType>().is_err());
1878 }
1879
1880 /// Every `impl_str_enum!` enum that also derives serde must agree on its wire
1881 /// string across BOTH representations: the macro's Display/FromStr/sqlx form
1882 /// (`VARIANTS`) and serde's JSON form. These are defined in two places (the
1883 /// macro literal and the `#[serde(rename_all = ...)]` attribute), so they can
1884 /// silently diverge, a variant travels via Display today and via serde
1885 /// tomorrow, and the string disagrees with what is already in Postgres. This
1886 /// test drives every serde-deriving enum through its `VARIANTS` (which round
1887 /// through `FromStr`) and asserts serde emits the identical string, in both
1888 /// directions. Add every serde-deriving enum here.
1889 macro_rules! assert_serde_matches_wire {
1890 ($($enum:ty),+ $(,)?) => {{
1891 $(
1892 for wire in <$enum>::VARIANTS {
1893 let value: $enum = wire.parse().unwrap_or_else(|e| {
1894 panic!("{}: VARIANTS string {wire:?} does not parse: {e}", stringify!($enum))
1895 });
1896 let json = serde_json::to_string(&value).expect("serialize");
1897 assert_eq!(
1898 json,
1899 format!("\"{wire}\""),
1900 "{}: serde emits {json} but the DB/Display wire string is {wire:?} \
1901 (add or fix `#[serde(rename_all = ...)]`)",
1902 stringify!($enum)
1903 );
1904 let back: $enum = serde_json::from_str(&json).unwrap_or_else(|e| {
1905 panic!("{}: serde cannot round-trip {json}: {e}", stringify!($enum))
1906 });
1907 assert_eq!(
1908 back.to_string(),
1909 *wire,
1910 "{}: serde deserialize of {json} disagrees with Display",
1911 stringify!($enum)
1912 );
1913 }
1914 )+
1915 }};
1916 }
1917
1918 #[test]
1919 fn serde_repr_matches_db_wire_repr_for_every_enum() {
1920 assert_serde_matches_wire!(
1921 DiscountType,
1922 CodePurpose,
1923 WaitlistStatus,
1924 SelectionMethod,
1925 TransactionStatus,
1926 FollowTargetType,
1927 SubscriptionStatus,
1928 SyncBillingStatus,
1929 SyncEnforcementMode,
1930 Visibility,
1931 ProjectRole,
1932 SyncOperation,
1933 SyncPlatform,
1934 FileScanStatus,
1935 InsertionPosition,
1936 AppealDecision,
1937 DiscoverSort,
1938 ItemType,
1939 IssueStatus,
1940 ReportTargetType,
1941 ReportType,
1942 ReportStatus,
1943 CreatorTier,
1944 AiTier,
1945 ProjectFeature,
1946 ProjectType,
1947 BuildStatus,
1948 PricingKind,
1949 MailingListType,
1950 ImportSource,
1951 ImportJobStatus,
1952 ModerationActionType,
1953 CheckoutType,
1954 );
1955 }
1956 }
1957