max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
12 files changed,
+1953 insertions,
-500 deletions
| @@ -1,2245 +1,0 @@ | |||
| 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 | - | impl WaitlistStatus { | |
| 146 | - | /// Badge vocabulary (charter: `docs/design-system.md`). | |
| 147 | - | pub fn badge_status(self) -> crate::types::BadgeStatus { | |
| 148 | - | use crate::types::BadgeStatus; | |
| 149 | - | match self { | |
| 150 | - | Self::Approved => BadgeStatus::Live, | |
| 151 | - | Self::Pending => BadgeStatus::Pending, | |
| 152 | - | Self::Spam => BadgeStatus::Failed, | |
| 153 | - | } | |
| 154 | - | } | |
| 155 | - | } | |
| 156 | - | ||
| 157 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 158 | - | pub enum SelectionMethod { | |
| 159 | - | #[serde(rename = "hand_picked")] | |
| 160 | - | HandPicked, | |
| 161 | - | #[serde(rename = "lottery")] | |
| 162 | - | Lottery, | |
| 163 | - | #[serde(rename = "invited")] | |
| 164 | - | Invited, | |
| 165 | - | } | |
| 166 | - | ||
| 167 | - | impl_str_enum!(SelectionMethod { | |
| 168 | - | HandPicked => "hand_picked", | |
| 169 | - | Lottery => "lottery", | |
| 170 | - | Invited => "invited", | |
| 171 | - | }); | |
| 172 | - | ||
| 173 | - | // --- Transactions --- | |
| 174 | - | ||
| 175 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 176 | - | #[serde(rename_all = "lowercase")] | |
| 177 | - | pub enum TransactionStatus { | |
| 178 | - | Pending, | |
| 179 | - | Completed, | |
| 180 | - | /// In-flight: a refund has been claimed (`completed -> refunding`) and sent to | |
| 181 | - | /// Stripe, but the `refund.created` webhook has not yet finalized it. Guards | |
| 182 | - | /// against double-submit on shared-cart PaymentIntents. | |
| 183 | - | Refunding, | |
| 184 | - | Refunded, | |
| 185 | - | /// Present in the DB `CHECK` since the initial schema but never written by | |
| 186 | - | /// the app (stale pending transactions are deleted, not failed). Kept as a | |
| 187 | - | /// variant so the enum can decode any legacy/manual `'failed'` row instead of | |
| 188 | - | /// fail-closed-poisoning the whole query, and so the enum-drift test's | |
| 189 | - | /// variant set matches the column constraint. | |
| 190 | - | Failed, | |
| 191 | - | } | |
| 192 | - | ||
| 193 | - | impl_str_enum!(TransactionStatus { | |
| 194 | - | Pending => "pending", | |
| 195 | - | Completed => "completed", | |
| 196 | - | Refunding => "refunding", | |
| 197 | - | Refunded => "refunded", | |
| 198 | - | Failed => "failed", | |
| 199 | - | }); | |
| 200 | - | ||
| 201 | - | impl TransactionStatus { | |
| 202 | - | /// Badge vocabulary (charter: `docs/design-system.md`). A refund is over | |
| 203 | - | /// and needs nobody, so it is neutral rather than red. | |
| 204 | - | pub fn badge_status(self) -> crate::types::BadgeStatus { | |
| 205 | - | use crate::types::BadgeStatus; | |
| 206 | - | match self { | |
| 207 | - | Self::Completed => BadgeStatus::Live, | |
| 208 | - | Self::Pending | Self::Refunding => BadgeStatus::Pending, | |
| 209 | - | Self::Failed => BadgeStatus::Failed, | |
| 210 | - | Self::Refunded => BadgeStatus::Ended, | |
| 211 | - | } | |
| 212 | - | } | |
| 213 | - | } | |
| 214 | - | ||
| 215 | - | // --- Follows --- | |
| 216 | - | ||
| 217 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 218 | - | #[serde(rename_all = "lowercase")] | |
| 219 | - | pub enum FollowTargetType { | |
| 220 | - | User, | |
| 221 | - | Project, | |
| 222 | - | Tag, | |
| 223 | - | } | |
| 224 | - | ||
| 225 | - | impl_str_enum!(FollowTargetType { | |
| 226 | - | User => "user", | |
| 227 | - | Project => "project", | |
| 228 | - | Tag => "tag", | |
| 229 | - | }); | |
| 230 | - | ||
| 231 | - | // --- Subscriptions --- | |
| 232 | - | ||
| 233 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 234 | - | pub enum SubscriptionStatus { | |
| 235 | - | #[serde(rename = "active")] | |
| 236 | - | Active, | |
| 237 | - | #[serde(rename = "trialing")] | |
| 238 | - | Trialing, | |
| 239 | - | #[serde(rename = "incomplete")] | |
| 240 | - | Incomplete, | |
| 241 | - | #[serde(rename = "incomplete_expired")] | |
| 242 | - | IncompleteExpired, | |
| 243 | - | #[serde(rename = "past_due")] | |
| 244 | - | PastDue, | |
| 245 | - | #[serde(rename = "canceled")] | |
| 246 | - | Canceled, | |
| 247 | - | #[serde(rename = "unpaid")] | |
| 248 | - | Unpaid, | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | impl_str_enum!(SubscriptionStatus { | |
| 252 | - | Active => "active", | |
| 253 | - | Trialing => "trialing", | |
| 254 | - | Incomplete => "incomplete", | |
| 255 | - | IncompleteExpired => "incomplete_expired", | |
| 256 | - | PastDue => "past_due", | |
| 257 | - | Canceled => "canceled", | |
| 258 | - | Unpaid => "unpaid", | |
| 259 | - | }); | |
| 260 | - | ||
| 261 | - | impl SubscriptionStatus { | |
| 262 | - | /// Badge vocabulary (charter: `docs/design-system.md`). A trial is live | |
| 263 | - | /// because the subscriber has access; a cancellation is over and needs | |
| 264 | - | /// nobody, so it is neutral rather than red. | |
| 265 | - | pub fn badge_status(self) -> crate::types::BadgeStatus { | |
| 266 | - | use crate::types::BadgeStatus; | |
| 267 | - | match self { | |
| 268 | - | Self::Active | Self::Trialing => BadgeStatus::Live, | |
| 269 | - | Self::Incomplete => BadgeStatus::Pending, | |
| 270 | - | Self::IncompleteExpired | Self::PastDue | Self::Unpaid => BadgeStatus::Failed, | |
| 271 | - | Self::Canceled => BadgeStatus::Ended, | |
| 272 | - | } | |
| 273 | - | } | |
| 274 | - | } | |
| 275 | - | ||
| 276 | - | // --- SyncKit developer billing --- | |
| 277 | - | ||
| 278 | - | /// Lifecycle of a SyncKit developer app's billing record (the `sync_apps.billing_status` | |
| 279 | - | /// TEXT column, CHECK-constrained in migration 117). Replaces the raw string the | |
| 280 | - | /// `DbSyncAppBilling` model used to carry, so a status comparison can't drift from the | |
| 281 | - | /// CHECK set. | |
| 282 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 283 | - | pub enum SyncBillingStatus { | |
| 284 | - | #[serde(rename = "draft")] | |
| 285 | - | Draft, | |
| 286 | - | #[serde(rename = "active")] | |
| 287 | - | Active, | |
| 288 | - | #[serde(rename = "suspended_unpaid")] | |
| 289 | - | SuspendedUnpaid, | |
| 290 | - | #[serde(rename = "canceled")] | |
| 291 | - | Canceled, | |
| 292 | - | } | |
| 293 | - | ||
| 294 | - | impl_str_enum!(SyncBillingStatus { | |
| 295 | - | Draft => "draft", | |
| 296 | - | Active => "active", | |
| 297 | - | SuspendedUnpaid => "suspended_unpaid", | |
| 298 | - | Canceled => "canceled", | |
| 299 | - | }); | |
| 300 | - | ||
| 301 | - | /// How a SyncKit developer app's storage billing is enforced (the | |
| 302 | - | /// `sync_apps.enforcement_mode` TEXT column, CHECK-constrained to `('per_key','bulk')` | |
| 303 | - | /// in migration 118). Replaces the raw string the `DbSyncAppBilling` model used to | |
| 304 | - | /// carry. Lifting this to an enum makes `monthly_price_cents` match exhaustively, so an | |
| 305 | - | /// unrecognized mode is no longer silently priced at the floor (Pay-S2). The historical | |
| 306 | - | /// `app_wide` value was renamed to `bulk` in migration 118; only these two are live. | |
| 307 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 308 | - | pub enum SyncEnforcementMode { | |
| 309 | - | #[serde(rename = "per_key")] | |
| 310 | - | PerKey, | |
| 311 | - | #[serde(rename = "bulk")] | |
| 312 | - | Bulk, | |
| 313 | - | } | |
| 314 | - | ||
| 315 | - | impl_str_enum!(SyncEnforcementMode { | |
| 316 | - | PerKey => "per_key", | |
| 317 | - | Bulk => "bulk", | |
| 318 | - | }); | |
| 319 | - | ||
| 320 | - | // --- Git repository visibility --- | |
| 321 | - | ||
| 322 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 323 | - | #[serde(rename_all = "lowercase")] | |
| 324 | - | pub enum Visibility { | |
| 325 | - | Public, | |
| 326 | - | Unlisted, | |
| 327 | - | Private, | |
| 328 | - | } | |
| 329 | - | ||
| 330 | - | impl_str_enum!(Visibility { | |
| 331 | - | Public => "public", | |
| 332 | - | Unlisted => "unlisted", | |
| 333 | - | Private => "private", | |
| 334 | - | }); | |
| 335 | - | ||
| 336 | - | // --- Git repository kind --- | |
| 337 | - | ||
| 338 | - | /// What a repository is for. | |
| 339 | - | /// | |
| 340 | - | /// `Source` is every repository a creator makes. `Annotations` is the one | |
| 341 | - | /// per-account repository holding nothing but `refs/notes/*`, which is private | |
| 342 | - | /// permanently. | |
| 343 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 344 | - | #[serde(rename_all = "lowercase")] | |
| 345 | - | pub enum GitRepoKind { | |
| 346 | - | Source, | |
| 347 | - | Annotations, | |
| 348 | - | } | |
| 349 | - | ||
| 350 | - | impl_str_enum!(GitRepoKind { | |
| 351 | - | Source => "source", | |
| 352 | - | Annotations => "annotations", | |
| 353 | - | }); | |
| 354 | - | ||
| 355 | - | // --- Project member roles --- | |
| 356 | - | ||
| 357 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 358 | - | #[serde(rename_all = "lowercase")] | |
| 359 | - | pub enum ProjectRole { | |
| 360 | - | Owner, | |
| 361 | - | Member, | |
| 362 | - | } | |
| 363 | - | ||
| 364 | - | impl_str_enum!(ProjectRole { | |
| 365 | - | Owner => "owner", | |
| 366 | - | Member => "member", | |
| 367 | - | }); | |
| 368 | - | ||
| 369 | - | // --- SyncKit --- | |
| 370 | - | ||
| 371 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 372 | - | pub enum SyncOperation { | |
| 373 | - | #[serde(rename = "INSERT")] | |
| 374 | - | Insert, | |
| 375 | - | #[serde(rename = "UPDATE")] | |
| 376 | - | Update, | |
| 377 | - | #[serde(rename = "DELETE")] | |
| 378 | - | Delete, | |
| 379 | - | } | |
| 380 | - | ||
| 381 | - | impl_str_enum!(SyncOperation { | |
| 382 | - | Insert => "INSERT", | |
| 383 | - | Update => "UPDATE", | |
| 384 | - | Delete => "DELETE", | |
| 385 | - | }); | |
| 386 | - | ||
| 387 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 388 | - | #[serde(rename_all = "lowercase")] | |
| 389 | - | pub enum SyncPlatform { | |
| 390 | - | Macos, | |
| 391 | - | Ios, | |
| 392 | - | Android, | |
| 393 | - | Windows, | |
| 394 | - | Linux, | |
| 395 | - | Web, | |
| 396 | - | } | |
| 397 | - | ||
| 398 | - | impl_str_enum!(SyncPlatform { | |
| 399 | - | Macos => "macos", | |
| 400 | - | Ios => "ios", | |
| 401 | - | Android => "android", | |
| 402 | - | Windows => "windows", | |
| 403 | - | Linux => "linux", | |
| 404 | - | Web => "web", | |
| 405 | - | }); | |
| 406 | - | ||
| 407 | - | // --- File scanning --- | |
| 408 | - | ||
| 409 | - | /// Status of an uploaded file in the scan pipeline. | |
| 410 | - | /// | |
| 411 | - | /// `Pending`, accepted, waiting in `scan_jobs` queue for a worker. | |
| 412 | - | /// `Scanning`, worker has claimed the job and is running the pipeline. | |
| 413 | - | /// `Clean`, pipeline completed, no Fail verdicts, no fail-closed Errors. | |
| 414 | - | /// `HeldForReview`, pipeline completed with a fail-closed Error, OR the | |
| 415 | - | /// uploader is untrusted (every untrusted upload routes to admin review). | |
| 416 | - | /// `Quarantined`, pipeline returned a Fail verdict on at least one layer. | |
| 417 | - | /// `Error`, pipeline itself crashed (worker exception, S3 fetch failed, etc.). | |
| 418 | - | /// | |
| 419 | - | /// Transitions are driven by `crate::scanning::final_status` and applied by | |
| 420 | - | /// `crate::scanning::worker`; `Pending` is the only entry state. | |
| 421 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 422 | - | #[serde(rename_all = "snake_case")] | |
| 423 | - | pub enum FileScanStatus { | |
| 424 | - | Pending, | |
| 425 | - | Scanning, | |
| 426 | - | Clean, | |
| 427 | - | Quarantined, | |
| 428 | - | HeldForReview, | |
| 429 | - | Error, | |
| 430 | - | } | |
| 431 | - | ||
| 432 | - | impl_str_enum!(FileScanStatus { | |
| 433 | - | Pending => "pending", | |
| 434 | - | Scanning => "scanning", | |
| 435 | - | Clean => "clean", | |
| 436 | - | Quarantined => "quarantined", | |
| 437 | - | HeldForReview => "held_for_review", | |
| 438 | - | Error => "error", | |
| 439 | - | }); | |
| 440 | - | ||
| 441 | - | // --- Content Insertions --- | |
| 442 | - | ||
| 443 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 444 | - | #[serde(rename_all = "snake_case")] | |
| 445 | - | pub enum InsertionPosition { | |
| 446 | - | PreRoll, | |
| 447 | - | MidRoll, | |
| 448 | - | PostRoll, | |
| 449 | - | } | |
| 450 | - | ||
| 451 | - | impl_str_enum!(InsertionPosition { | |
| 452 | - | PreRoll => "pre_roll", | |
| 453 | - | MidRoll => "mid_roll", | |
| 454 | - | PostRoll => "post_roll", | |
| 455 | - | }); | |
| 456 | - | ||
| 457 | - | // --- Appeals --- | |
| 458 | - | ||
| 459 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 460 | - | #[serde(rename_all = "lowercase")] | |
| 461 | - | pub enum AppealDecision { | |
| 462 | - | Approved, | |
| 463 | - | Denied, | |
| 464 | - | } | |
| 465 | - | ||
| 466 | - | impl_str_enum!(AppealDecision { | |
| 467 | - | Approved => "approved", | |
| 468 | - | Denied => "denied", | |
| 469 | - | }); | |
| 470 | - | ||
| 471 | - | // --- Discover sorting --- | |
| 472 | - | ||
| 473 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 474 | - | #[serde(rename_all = "snake_case")] | |
| 475 | - | pub enum DiscoverSort { | |
| 476 | - | Newest, | |
| 477 | - | MostSold, | |
| 478 | - | PriceAsc, | |
| 479 | - | PriceDesc, | |
| 480 | - | } | |
| 481 | - | ||
| 482 | - | impl_str_enum!(DiscoverSort { | |
| 483 | - | Newest => "newest", | |
| 484 | - | MostSold => "most_sold", | |
| 485 | - | PriceAsc => "price_asc", | |
| 486 | - | PriceDesc => "price_desc", | |
| 487 | - | }); | |
| 488 | - | ||
| 489 | - | // --- Items --- | |
| 490 | - | ||
| 491 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 492 | - | #[serde(rename_all = "lowercase")] | |
| 493 | - | pub enum ItemType { | |
| 494 | - | Audio, | |
| 495 | - | Text, | |
| 496 | - | Video, | |
| 497 | - | Image, | |
| 498 | - | Plugin, | |
| 499 | - | Preset, | |
| 500 | - | Sample, |
Lines truncated
| @@ -1,0 +1,237 @@ | |||
| 1 | + | //! People and the standing they hold: waitlist and selection, project roles, | |
| 2 | + | //! creator tiers, and the reporting and moderation vocabulary. | |
| 3 | + | ||
| 4 | + | use super::str_enum::impl_str_enum; | |
| 5 | + | use serde::{Deserialize, Serialize}; | |
| 6 | + | ||
| 7 | + | // --- Waitlist --- | |
| 8 | + | ||
| 9 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 10 | + | #[serde(rename_all = "lowercase")] | |
| 11 | + | pub enum WaitlistStatus { | |
| 12 | + | Pending, | |
| 13 | + | Approved, | |
| 14 | + | Spam, | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | impl_str_enum!(WaitlistStatus { | |
| 18 | + | Pending => "pending", | |
| 19 | + | Approved => "approved", | |
| 20 | + | Spam => "spam", | |
| 21 | + | }); | |
| 22 | + | ||
| 23 | + | impl WaitlistStatus { | |
| 24 | + | /// Badge vocabulary (charter: `docs/design-system.md`). | |
| 25 | + | pub fn badge_status(self) -> crate::types::BadgeStatus { | |
| 26 | + | use crate::types::BadgeStatus; | |
| 27 | + | match self { | |
| 28 | + | Self::Approved => BadgeStatus::Live, | |
| 29 | + | Self::Pending => BadgeStatus::Pending, | |
| 30 | + | Self::Spam => BadgeStatus::Failed, | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 36 | + | pub enum SelectionMethod { | |
| 37 | + | #[serde(rename = "hand_picked")] | |
| 38 | + | HandPicked, | |
| 39 | + | #[serde(rename = "lottery")] | |
| 40 | + | Lottery, | |
| 41 | + | #[serde(rename = "invited")] | |
| 42 | + | Invited, | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | impl_str_enum!(SelectionMethod { | |
| 46 | + | HandPicked => "hand_picked", | |
| 47 | + | Lottery => "lottery", | |
| 48 | + | Invited => "invited", | |
| 49 | + | }); | |
| 50 | + | ||
| 51 | + | // --- Follows --- | |
| 52 | + | ||
| 53 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 54 | + | #[serde(rename_all = "lowercase")] | |
| 55 | + | pub enum FollowTargetType { | |
| 56 | + | User, | |
| 57 | + | Project, | |
| 58 | + | Tag, | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | impl_str_enum!(FollowTargetType { | |
| 62 | + | User => "user", | |
| 63 | + | Project => "project", | |
| 64 | + | Tag => "tag", | |
| 65 | + | }); | |
| 66 | + | ||
| 67 | + | // --- Project member roles --- | |
| 68 | + | ||
| 69 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 70 | + | #[serde(rename_all = "lowercase")] | |
| 71 | + | pub enum ProjectRole { | |
| 72 | + | Owner, | |
| 73 | + | Member, | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | impl_str_enum!(ProjectRole { | |
| 77 | + | Owner => "owner", | |
| 78 | + | Member => "member", | |
| 79 | + | }); | |
| 80 | + | ||
| 81 | + | // --- Appeals --- | |
| 82 | + | ||
| 83 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 84 | + | #[serde(rename_all = "lowercase")] | |
| 85 | + | pub enum AppealDecision { | |
| 86 | + | Approved, | |
| 87 | + | Denied, | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | impl_str_enum!(AppealDecision { | |
| 91 | + | Approved => "approved", | |
| 92 | + | Denied => "denied", | |
| 93 | + | }); | |
| 94 | + | ||
| 95 | + | // --- Reports --- | |
| 96 | + | ||
| 97 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 98 | + | #[serde(rename_all = "lowercase")] | |
| 99 | + | pub enum ReportTargetType { | |
| 100 | + | Project, | |
| 101 | + | Item, | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | impl_str_enum!(ReportTargetType { | |
| 105 | + | Project => "project", | |
| 106 | + | Item => "item", | |
| 107 | + | }); | |
| 108 | + | ||
| 109 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 110 | + | #[serde(rename_all = "lowercase")] | |
| 111 | + | pub enum ReportType { | |
| 112 | + | Mislabeled, | |
| 113 | + | Spam, | |
| 114 | + | Abuse, | |
| 115 | + | Infringement, | |
| 116 | + | Other, | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | impl_str_enum!(ReportType { | |
| 120 | + | Mislabeled => "mislabeled", | |
| 121 | + | Spam => "spam", | |
| 122 | + | Abuse => "abuse", | |
| 123 | + | Infringement => "infringement", | |
| 124 | + | Other => "other", | |
| 125 | + | }); | |
| 126 | + | ||
| 127 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 128 | + | #[serde(rename_all = "lowercase")] | |
| 129 | + | pub enum ReportStatus { | |
| 130 | + | Open, | |
| 131 | + | Resolved, | |
| 132 | + | Dismissed, | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | impl_str_enum!(ReportStatus { | |
| 136 | + | Open => "open", | |
| 137 | + | Resolved => "resolved", | |
| 138 | + | Dismissed => "dismissed", | |
| 139 | + | }); | |
| 140 | + | ||
| 141 | + | // --- Creator Tiers --- | |
| 142 | + | ||
| 143 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 144 | + | #[serde(rename_all = "snake_case")] | |
| 145 | + | pub enum CreatorTier { | |
| 146 | + | Basic, | |
| 147 | + | SmallFiles, | |
| 148 | + | BigFiles, | |
| 149 | + | Everything, | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | impl_str_enum!(CreatorTier { | |
| 153 | + | Basic => "basic", | |
| 154 | + | SmallFiles => "small_files", | |
| 155 | + | BigFiles => "big_files", | |
| 156 | + | Everything => "everything", | |
| 157 | + | }); | |
| 158 | + | ||
| 159 | + | impl CreatorTier { | |
| 160 | + | /// Human-readable label for display. | |
| 161 | + | pub fn label(&self) -> &'static str { | |
| 162 | + | match self { | |
| 163 | + | Self::Basic => "Basic", | |
| 164 | + | Self::SmallFiles => "Small Files", | |
| 165 | + | Self::BigFiles => "Big Files", | |
| 166 | + | Self::Everything => "Everything", | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | /// Monthly standard price in cents. Reads from the process-global | |
| 171 | + | /// `TierPrices` installed at startup from `assumptions.toml`. See | |
| 172 | + | /// `crate::tier_prices` for the OnceLock and test-setup helper. | |
| 173 | + | pub fn price_cents(&self) -> i32 { | |
| 174 | + | crate::tier_prices::TierPrices::global().price_cents_for(*self) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | /// Maximum per-file upload size in bytes. Reads from the global | |
| 178 | + | /// `TierPrices` (see `price_cents`). | |
| 179 | + | pub fn max_file_bytes(&self) -> i64 { | |
| 180 | + | crate::tier_prices::TierPrices::global().max_file_bytes_for(*self) | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | /// Maximum total storage in bytes. Reads from the global `TierPrices` | |
| 184 | + | /// (see `price_cents`). | |
| 185 | + | pub fn max_storage_bytes(&self) -> i64 { | |
| 186 | + | crate::tier_prices::TierPrices::global().max_storage_bytes_for(*self) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | /// Whether this tier allows non-cover file uploads (audio, downloads, insertions). | |
| 190 | + | /// Basic is text-only; covers are always allowed regardless of tier. | |
| 191 | + | pub fn allows_file_uploads(&self) -> bool { | |
| 192 | + | !matches!(self, Self::Basic) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | /// Capability strings exposed to external OAuth implementers via `/oauth/userinfo`. | |
| 196 | + | /// | |
| 197 | + | /// Implementers gate features on these strings rather than tier names so the | |
| 198 | + | /// tier lineup can change without breaking callers. Only ship strings backed by | |
| 199 | + | /// live behavior; new capabilities are added when they actually launch. | |
| 200 | + | pub fn features(&self) -> &'static [&'static str] { | |
| 201 | + | match self { | |
| 202 | + | Self::Basic => &[], | |
| 203 | + | Self::SmallFiles => &["file_uploads"], | |
| 204 | + | Self::BigFiles => &["file_uploads", "large_files"], | |
| 205 | + | Self::Everything => &["file_uploads", "large_files"], | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 211 | + | #[serde(rename_all = "snake_case")] | |
| 212 | + | pub enum ModerationActionType { | |
| 213 | + | Warning, | |
| 214 | + | Suspension, | |
| 215 | + | Termination, | |
| 216 | + | ContentRemoval, | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | impl_str_enum!(ModerationActionType { | |
| 220 | + | Warning => "warning", | |
| 221 | + | Suspension => "suspension", | |
| 222 | + | Termination => "termination", | |
| 223 | + | ContentRemoval => "content_removal", | |
| 224 | + | }); | |
| 225 | + | ||
| 226 | + | // Checkout types (Stripe metadata) | |
| 227 | + | ||
| 228 | + | impl ModerationActionType { | |
| 229 | + | pub fn label(&self) -> &'static str { | |
| 230 | + | match self { | |
| 231 | + | Self::Warning => "Warning", | |
| 232 | + | Self::Suspension => "Suspension", | |
| 233 | + | Self::Termination => "Termination", | |
| 234 | + | Self::ContentRemoval => "Content Removal", | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | } |
| @@ -1,0 +1,443 @@ | |||
| 1 | + | //! What a creator publishes: item and project kinds, the features a project | |
| 2 | + | //! can switch on, the AI-disclosure tiers, and how discover sorts it all. | |
| 3 | + | ||
| 4 | + | use super::str_enum::impl_str_enum; | |
| 5 | + | use serde::{Deserialize, Serialize}; | |
| 6 | + | ||
| 7 | + | // --- Content Insertions --- | |
| 8 | + | ||
| 9 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 10 | + | #[serde(rename_all = "snake_case")] | |
| 11 | + | pub enum InsertionPosition { | |
| 12 | + | PreRoll, | |
| 13 | + | MidRoll, | |
| 14 | + | PostRoll, | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | impl_str_enum!(InsertionPosition { | |
| 18 | + | PreRoll => "pre_roll", | |
| 19 | + | MidRoll => "mid_roll", | |
| 20 | + | PostRoll => "post_roll", | |
| 21 | + | }); | |
| 22 | + | ||
| 23 | + | // --- Discover sorting --- | |
| 24 | + | ||
| 25 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 26 | + | #[serde(rename_all = "snake_case")] | |
| 27 | + | pub enum DiscoverSort { | |
| 28 | + | Newest, | |
| 29 | + | MostSold, | |
| 30 | + | PriceAsc, | |
| 31 | + | PriceDesc, | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | impl_str_enum!(DiscoverSort { | |
| 35 | + | Newest => "newest", | |
| 36 | + | MostSold => "most_sold", | |
| 37 | + | PriceAsc => "price_asc", | |
| 38 | + | PriceDesc => "price_desc", | |
| 39 | + | }); | |
| 40 | + | ||
| 41 | + | // --- Items --- | |
| 42 | + | ||
| 43 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 44 | + | #[serde(rename_all = "lowercase")] | |
| 45 | + | pub enum ItemType { | |
| 46 | + | Audio, | |
| 47 | + | Text, | |
| 48 | + | Video, | |
| 49 | + | Image, | |
| 50 | + | Plugin, | |
| 51 | + | Preset, | |
| 52 | + | Sample, | |
| 53 | + | Course, | |
| 54 | + | Template, | |
| 55 | + | Digital, | |
| 56 | + | Bundle, | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | impl_str_enum!(ItemType { | |
| 60 | + | Audio => "audio", | |
| 61 | + | Text => "text", | |
| 62 | + | Video => "video", | |
| 63 | + | Image => "image", | |
| 64 | + | Plugin => "plugin", | |
| 65 | + | Preset => "preset", | |
| 66 | + | Sample => "sample", | |
| 67 | + | Course => "course", | |
| 68 | + | Template => "template", | |
| 69 | + | Digital => "digital", | |
| 70 | + | Bundle => "bundle", | |
| 71 | + | }); | |
| 72 | + | ||
| 73 | + | impl ItemType { | |
| 74 | + | /// Short human-readable label for display (replaces `helpers::get_item_type_label`). | |
| 75 | + | pub fn label(&self) -> &'static str { | |
| 76 | + | match self { | |
| 77 | + | Self::Audio => "Audio", | |
| 78 | + | Self::Text => "Text", | |
| 79 | + | Self::Video => "Video", | |
| 80 | + | Self::Image => "Image", | |
| 81 | + | Self::Plugin => "Plugin", | |
| 82 | + | Self::Preset => "Preset", | |
| 83 | + | Self::Sample => "Sample", | |
| 84 | + | Self::Course => "Course", | |
| 85 | + | Self::Template => "Template", | |
| 86 | + | Self::Digital => "Digital", | |
| 87 | + | Self::Bundle => "Bundle", | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// Which wizard content-input group this type belongs to. | |
| 92 | + | /// | |
| 93 | + | /// Determines what the content step looks like: | |
| 94 | + | /// - `"text"` → Markdown editor | |
| 95 | + | /// - `"audio"` → Audio file upload | |
| 96 | + | /// - `"video"` → Video file upload | |
| 97 | + | /// - `"bundle"` → Item picker for bundle contents | |
| 98 | + | /// - `"file"` → Generic file upload | |
| 99 | + | pub fn wizard_group(&self) -> &'static str { | |
| 100 | + | match self { | |
| 101 | + | Self::Text => "text", | |
| 102 | + | Self::Audio => "audio", | |
| 103 | + | Self::Video => "video", | |
| 104 | + | Self::Bundle => "bundle", | |
| 105 | + | _ => "file", | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // --- AI Tiers --- | |
| 111 | + | ||
| 112 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 113 | + | #[serde(rename_all = "snake_case")] | |
| 114 | + | pub enum AiTier { | |
| 115 | + | Handmade, | |
| 116 | + | Assisted, | |
| 117 | + | Generated, | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | impl_str_enum!(AiTier { | |
| 121 | + | Handmade => "handmade", | |
| 122 | + | Assisted => "assisted", | |
| 123 | + | Generated => "generated", | |
| 124 | + | }); | |
| 125 | + | ||
| 126 | + | impl AiTier { | |
| 127 | + | pub fn label(&self) -> &'static str { | |
| 128 | + | match self { | |
| 129 | + | Self::Handmade => "Handmade", | |
| 130 | + | Self::Assisted => "Assisted", | |
| 131 | + | Self::Generated => "Generated", | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | /// The badge modifier for this tier. A disclosure level is not lifecycle, | |
| 136 | + | /// so it keeps its own names rather than joining the status set, but the | |
| 137 | + | /// class still comes from here rather than from the serialized value. | |
| 138 | + | pub fn css_class(&self) -> &'static str { | |
| 139 | + | match self { | |
| 140 | + | Self::Handmade => "ai-tier-handmade", | |
| 141 | + | Self::Assisted => "ai-tier-assisted", | |
| 142 | + | Self::Generated => "ai-tier-generated", | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | /// Discover-page filter shape per `about/generative-ai.md` § "How Fans | |
| 148 | + | /// Use This". Distinct from `AiTier` because this is a *filter*, not a | |
| 149 | + | /// per-item value: `HumanLed` aggregates the Handmade + Assisted tiers. | |
| 150 | + | /// `None` on `DiscoverFilters.ai_tier` means "Everything", no | |
| 151 | + | /// restriction. | |
| 152 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] | |
| 153 | + | pub enum AiTierFilter { | |
| 154 | + | HandmadeOnly, | |
| 155 | + | HumanLed, | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | impl_str_enum!(AiTierFilter { | |
| 159 | + | HandmadeOnly => "handmade_only", | |
| 160 | + | HumanLed => "human_led", | |
| 161 | + | }); | |
| 162 | + | ||
| 163 | + | impl AiTierFilter { | |
| 164 | + | pub fn label(&self) -> &'static str { | |
| 165 | + | match self { | |
| 166 | + | Self::HandmadeOnly => "Handmade only", | |
| 167 | + | Self::HumanLed => "Human-led", | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // --- Project Features --- | |
| 173 | + | ||
| 174 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 175 | + | #[serde(rename_all = "snake_case")] | |
| 176 | + | pub enum ProjectFeature { | |
| 177 | + | Audio, | |
| 178 | + | Downloads, | |
| 179 | + | Text, | |
| 180 | + | Blog, | |
| 181 | + | Subscriptions, | |
| 182 | + | LicenseKeys, | |
| 183 | + | SourceCode, | |
| 184 | + | CloudSync, | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | impl_str_enum!(ProjectFeature { | |
| 188 | + | Audio => "audio", | |
| 189 | + | Downloads => "downloads", | |
| 190 | + | Text => "text", | |
| 191 | + | Blog => "blog", | |
| 192 | + | Subscriptions => "subscriptions", | |
| 193 | + | LicenseKeys => "license_keys", | |
| 194 | + | SourceCode => "source_code", | |
| 195 | + | CloudSync => "cloud_sync", | |
| 196 | + | }); | |
| 197 | + | ||
| 198 | + | impl ProjectFeature { | |
| 199 | + | /// Human-readable label for display. | |
| 200 | + | pub fn label(&self) -> &'static str { | |
| 201 | + | match self { | |
| 202 | + | Self::Audio => "Audio", | |
| 203 | + | Self::Downloads => "Downloads", | |
| 204 | + | Self::Text => "Text", | |
| 205 | + | Self::Blog => "Blog", | |
| 206 | + | Self::Subscriptions => "Subscriptions", | |
| 207 | + | Self::LicenseKeys => "License Keys", | |
| 208 | + | Self::SourceCode => "Source Code", | |
| 209 | + | Self::CloudSync => "Cloud Sync", | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | /// One-line description of what this feature enables. | |
| 214 | + | pub fn description(&self) -> &'static str { | |
| 215 | + | match self { | |
| 216 | + | Self::Audio => "Upload and stream audio files. Player with chapters.", | |
| 217 | + | Self::Downloads => "Host file downloads with versioned releases.", | |
| 218 | + | Self::Text => "Write and publish text content with markdown.", | |
| 219 | + | Self::Blog => "Project blog with RSS feed.", | |
| 220 | + | Self::Subscriptions => "Monthly subscriber tiers.", | |
| 221 | + | Self::LicenseKeys => "Software license management with activation API.", | |
| 222 | + | Self::SourceCode => "Git repository with source browser.", | |
| 223 | + | Self::CloudSync => "E2E encrypted cloud sync for desktop and mobile apps.", | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | /// All features as (value, label, description) tuples for form rendering. | |
| 228 | + | pub fn all() -> &'static [(&'static str, &'static str, &'static str)] { | |
| 229 | + | &[ | |
| 230 | + | ( | |
| 231 | + | "audio", | |
| 232 | + | "Audio", | |
| 233 | + | "Upload and stream audio files. Player with chapters.", | |
| 234 | + | ), | |
| 235 | + | ( | |
| 236 | + | "downloads", | |
| 237 | + | "Downloads", | |
| 238 | + | "Host file downloads with versioned releases.", | |
| 239 | + | ), | |
| 240 | + | ( | |
| 241 | + | "text", | |
| 242 | + | "Text", | |
| 243 | + | "Write and publish text content with markdown.", | |
| 244 | + | ), | |
| 245 | + | ("blog", "Blog", "Project blog with RSS feed."), | |
| 246 | + | ( | |
| 247 | + | "subscriptions", | |
| 248 | + | "Subscriptions", | |
| 249 | + | "Monthly subscriber tiers.", | |
| 250 | + | ), | |
| 251 | + | ( | |
| 252 | + | "license_keys", | |
| 253 | + | "License Keys", | |
| 254 | + | "Software license management with activation API.", | |
| 255 | + | ), | |
| 256 | + | ( | |
| 257 | + | "source_code", | |
| 258 | + | "Source Code", | |
| 259 | + | "Git repository with source browser.", | |
| 260 | + | ), | |
| 261 | + | ( | |
| 262 | + | "cloud_sync", | |
| 263 | + | "Cloud Sync", | |
| 264 | + | "E2E encrypted cloud sync for desktop and mobile apps.", | |
| 265 | + | ), | |
| 266 | + | ] | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | /// Derive the best-fit project type from a set of features. | |
| 270 | + | pub fn derive_project_type(features: &[String]) -> ProjectType { | |
| 271 | + | if features.iter().any(|f| f == "audio") { | |
| 272 | + | return ProjectType::Music; | |
| 273 | + | } | |
| 274 | + | if features.iter().any(|f| f == "text") && !features.iter().any(|f| f == "downloads") { | |
| 275 | + | return ProjectType::Blog; | |
| 276 | + | } | |
| 277 | + | if features.iter().any(|f| f == "downloads") { | |
| 278 | + | return ProjectType::Software; | |
| 279 | + | } | |
| 280 | + | ProjectType::General | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | /// Which item types a feature unlocks. | |
| 284 | + | pub fn allowed_item_types(&self) -> &'static [ItemType] { | |
| 285 | + | match self { | |
| 286 | + | Self::Audio => &[ItemType::Audio, ItemType::Sample, ItemType::Preset], | |
| 287 | + | Self::Downloads => &[ | |
| 288 | + | ItemType::Digital, | |
| 289 | + | ItemType::Plugin, | |
| 290 | + | ItemType::Template, | |
| 291 | + | ItemType::Course, | |
| 292 | + | ItemType::Image, | |
| 293 | + | ItemType::Video, | |
| 294 | + | ], | |
| 295 | + | Self::Text => &[ItemType::Text], | |
| 296 | + | // Non-content features don't gate item types | |
| 297 | + | Self::Blog | |
| 298 | + | | Self::Subscriptions | |
| 299 | + | | Self::LicenseKeys | |
| 300 | + | | Self::SourceCode | |
| 301 | + | | Self::CloudSync => &[], | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | /// Compute the set of item types allowed by a project's feature list. | |
| 306 | + | /// If no content features are enabled, all types are allowed (permissive default). | |
| 307 | + | pub fn allowed_item_type_cards( | |
| 308 | + | features: &[String], | |
| 309 | + | ) -> Vec<(&'static str, &'static str, &'static str)> { | |
| 310 | + | let allowed: std::collections::HashSet<ItemType> = features | |
| 311 | + | .iter() | |
| 312 | + | .filter_map(|f| f.parse::<ProjectFeature>().ok()) | |
| 313 | + | .flat_map(|f| f.allowed_item_types().iter().copied()) | |
| 314 | + | .collect(); | |
| 315 | + | ||
| 316 | + | // If no content features enabled, show all types (backwards compat) | |
| 317 | + | if allowed.is_empty() { | |
| 318 | + | return Self::all_item_type_cards().to_vec(); | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | Self::all_item_type_cards() | |
| 322 | + | .iter() | |
| 323 | + | .filter(|(value, _, _)| { | |
| 324 | + | value | |
| 325 | + | .parse::<ItemType>() | |
| 326 | + | .is_ok_and(|t| t == ItemType::Bundle || allowed.contains(&t)) | |
| 327 | + | }) | |
| 328 | + | .copied() | |
| 329 | + | .collect() | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | /// All item type cards: (value, label, description) tuples for form rendering. | |
| 333 | + | pub fn all_item_type_cards() -> &'static [(&'static str, &'static str, &'static str)] { | |
| 334 | + | &[ | |
| 335 | + | ("audio", "Audio", "Podcast, music, sound effects"), | |
| 336 | + | ("text", "Text", "Articles, posts, essays, guides"), | |
| 337 | + | ("digital", "Digital Download", "Files, archives, documents"), | |
| 338 | + | ("video", "Video", "Tutorials, films, recordings"), | |
| 339 | + | ("course", "Course", "Multi-part lessons, curricula"), | |
| 340 | + | ("plugin", "Plugin", "Software extensions, add-ons"), | |
| 341 | + | ("sample", "Sample Pack", "Audio samples, loops, one-shots"), | |
| 342 | + | ("preset", "Preset Pack", "Synth presets, effect chains"), | |
| 343 | + | ("template", "Template", "Design templates, starter kits"), | |
| 344 | + | ("image", "Image", "Photos, artwork, graphics"), | |
| 345 | + | ("bundle", "Bundle", "Collection of other items"), | |
| 346 | + | ] | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | /// Item type cards filtered to one per distinct wizard behavior group. | |
| 350 | + | /// | |
| 351 | + | /// The wizard only needs a type selector when the allowed types produce | |
| 352 | + | /// different content-step UIs (text editor vs audio upload vs file upload). | |
| 353 | + | /// Returns one card per group, using the first allowed type as the value. | |
| 354 | + | /// If all types share one group, returns a single card (caller should skip | |
| 355 | + | /// the type step entirely). | |
| 356 | + | pub fn wizard_type_cards( | |
| 357 | + | features: &[String], | |
| 358 | + | ) -> Vec<(&'static str, &'static str, &'static str)> { | |
| 359 | + | let allowed = Self::allowed_item_type_cards(features); | |
| 360 | + | let mut seen_groups = std::collections::HashSet::new(); | |
| 361 | + | let mut cards = Vec::new(); | |
| 362 | + | ||
| 363 | + | for (value, _, _) in &allowed { | |
| 364 | + | let Ok(item_type) = value.parse::<ItemType>() else { | |
| 365 | + | continue; | |
| 366 | + | }; | |
| 367 | + | let group = item_type.wizard_group(); | |
| 368 | + | if seen_groups.insert(group) { | |
| 369 | + | let (label, desc) = match group { | |
| 370 | + | "text" => ("Text", "Write in the editor"), | |
| 371 | + | "audio" => ("Audio", "Upload audio files"), | |
| 372 | + | "video" => ("Video", "Upload video files"), | |
| 373 | + | "bundle" => ("Bundle", "Collection of other items"), | |
| 374 | + | _ => ("File", "Upload any file"), | |
| 375 | + | }; | |
| 376 | + | cards.push((*value, label, desc)); | |
| 377 | + | } | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | cards | |
| 381 | + | } | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | // --- Projects --- | |
| 385 | + | ||
| 386 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] | |
| 387 | + | #[serde(rename_all = "lowercase")] | |
| 388 | + | pub enum ProjectType { | |
| 389 | + | Blog, | |
| 390 | + | Book, | |
| 391 | + | Podcast, | |
| 392 | + | Course, | |
| 393 | + | Music, | |
| 394 | + | Software, | |
| 395 | + | Art, | |
| 396 | + | Writing, | |
| 397 | + | #[default] | |
| 398 | + | General, | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | impl_str_enum!(ProjectType { | |
| 402 | + | Blog => "blog", | |
| 403 | + | Book => "book", | |
| 404 | + | Podcast => "podcast", | |
| 405 | + | Course => "course", | |
| 406 | + | Music => "music", | |
| 407 | + | Software => "software", | |
| 408 | + | Art => "art", | |
| 409 | + | Writing => "writing", | |
| 410 | + | General => "general", | |
| 411 | + | }); | |
| 412 | + | ||
| 413 | + | impl ProjectType { | |
| 414 | + | /// Human-readable label for display. | |
| 415 | + | pub fn label(&self) -> &'static str { | |
| 416 | + | match self { | |
| 417 | + | Self::Blog => "Blog", | |
| 418 | + | Self::Book => "Book", | |
| 419 | + | Self::Podcast => "Podcast", | |
| 420 | + | Self::Course => "Course", | |
| 421 | + | Self::Music => "Music", | |
| 422 | + | Self::Software => "Software", | |
| 423 | + | Self::Art => "Art", | |
| 424 | + | Self::Writing => "Writing", | |
| 425 | + | Self::General => "General", | |
| 426 | + | } | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | /// All valid project types as (value, label) pairs for form rendering. | |
| 430 | + | pub fn all() -> &'static [(&'static str, &'static str)] { | |
| 431 | + | &[ | |
| 432 | + | ("blog", "Blog"), | |
| 433 | + | ("book", "Book"), | |
| 434 | + | ("podcast", "Podcast"), | |
| 435 | + | ("course", "Course"), | |
| 436 | + | ("music", "Music"), | |
| 437 | + | ("software", "Software"), | |
| 438 | + | ("art", "Art"), | |
| 439 | + | ("writing", "Writing"), | |
| 440 | + | ("general", "General"), | |
| 441 | + | ] | |
| 442 | + | } | |
| 443 | + | } |
| @@ -1,0 +1,164 @@ | |||
| 1 | + | //! Money moving: what a discount is, what a promo code is for, and the state | |
| 2 | + | //! machines a transaction and a subscription each walk. | |
| 3 | + | ||
| 4 | + | use super::str_enum::impl_str_enum; | |
| 5 | + | use serde::{Deserialize, Serialize}; | |
| 6 | + | ||
| 7 | + | // --- Discount codes --- | |
| 8 | + | ||
| 9 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 10 | + | #[serde(rename_all = "lowercase")] | |
| 11 | + | pub enum DiscountType { | |
| 12 | + | Percentage, | |
| 13 | + | Fixed, | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | impl_str_enum!(DiscountType { | |
| 17 | + | Percentage => "percentage", | |
| 18 | + | Fixed => "fixed", | |
| 19 | + | }); | |
| 20 | + | ||
| 21 | + | // --- Promo codes --- | |
| 22 | + | ||
| 23 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 24 | + | #[serde(rename_all = "snake_case")] | |
| 25 | + | pub enum CodePurpose { | |
| 26 | + | Discount, | |
| 27 | + | FreeAccess, | |
| 28 | + | FreeTrial, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | impl_str_enum!(CodePurpose { | |
| 32 | + | Discount => "discount", | |
| 33 | + | FreeAccess => "free_access", | |
| 34 | + | FreeTrial => "free_trial", | |
| 35 | + | }); | |
| 36 | + | ||
| 37 | + | // --- Transactions --- | |
| 38 | + | ||
| 39 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 40 | + | #[serde(rename_all = "lowercase")] | |
| 41 | + | pub enum TransactionStatus { | |
| 42 | + | Pending, | |
| 43 | + | Completed, | |
| 44 | + | /// In-flight: a refund has been claimed (`completed -> refunding`) and sent to | |
| 45 | + | /// Stripe, but the `refund.created` webhook has not yet finalized it. Guards | |
| 46 | + | /// against double-submit on shared-cart PaymentIntents. | |
| 47 | + | Refunding, | |
| 48 | + | Refunded, | |
| 49 | + | /// Present in the DB `CHECK` since the initial schema but never written by | |
| 50 | + | /// the app (stale pending transactions are deleted, not failed). Kept as a | |
| 51 | + | /// variant so the enum can decode any legacy/manual `'failed'` row instead of | |
| 52 | + | /// fail-closed-poisoning the whole query, and so the enum-drift test's | |
| 53 | + | /// variant set matches the column constraint. | |
| 54 | + | Failed, | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | impl_str_enum!(TransactionStatus { | |
| 58 | + | Pending => "pending", | |
| 59 | + | Completed => "completed", | |
| 60 | + | Refunding => "refunding", | |
| 61 | + | Refunded => "refunded", | |
| 62 | + | Failed => "failed", | |
| 63 | + | }); | |
| 64 | + | ||
| 65 | + | impl TransactionStatus { | |
| 66 | + | /// Badge vocabulary (charter: `docs/design-system.md`). A refund is over | |
| 67 | + | /// and needs nobody, so it is neutral rather than red. | |
| 68 | + | pub fn badge_status(self) -> crate::types::BadgeStatus { | |
| 69 | + | use crate::types::BadgeStatus; | |
| 70 | + | match self { | |
| 71 | + | Self::Completed => BadgeStatus::Live, | |
| 72 | + | Self::Pending | Self::Refunding => BadgeStatus::Pending, | |
| 73 | + | Self::Failed => BadgeStatus::Failed, | |
| 74 | + | Self::Refunded => BadgeStatus::Ended, | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // --- Subscriptions --- | |
| 80 | + | ||
| 81 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 82 | + | pub enum SubscriptionStatus { | |
| 83 | + | #[serde(rename = "active")] | |
| 84 | + | Active, | |
| 85 | + | #[serde(rename = "trialing")] | |
| 86 | + | Trialing, | |
| 87 | + | #[serde(rename = "incomplete")] | |
| 88 | + | Incomplete, | |
| 89 | + | #[serde(rename = "incomplete_expired")] | |
| 90 | + | IncompleteExpired, | |
| 91 | + | #[serde(rename = "past_due")] | |
| 92 | + | PastDue, | |
| 93 | + | #[serde(rename = "canceled")] | |
| 94 | + | Canceled, | |
| 95 | + | #[serde(rename = "unpaid")] | |
| 96 | + | Unpaid, | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | impl_str_enum!(SubscriptionStatus { | |
| 100 | + | Active => "active", | |
| 101 | + | Trialing => "trialing", | |
| 102 | + | Incomplete => "incomplete", | |
| 103 | + | IncompleteExpired => "incomplete_expired", | |
| 104 | + | PastDue => "past_due", | |
| 105 | + | Canceled => "canceled", | |
| 106 | + | Unpaid => "unpaid", | |
| 107 | + | }); | |
| 108 | + | ||
| 109 | + | impl SubscriptionStatus { | |
| 110 | + | /// Badge vocabulary (charter: `docs/design-system.md`). A trial is live | |
| 111 | + | /// because the subscriber has access; a cancellation is over and needs | |
| 112 | + | /// nobody, so it is neutral rather than red. | |
| 113 | + | pub fn badge_status(self) -> crate::types::BadgeStatus { | |
| 114 | + | use crate::types::BadgeStatus; | |
| 115 | + | match self { | |
| 116 | + | Self::Active | Self::Trialing => BadgeStatus::Live, | |
| 117 | + | Self::Incomplete => BadgeStatus::Pending, | |
| 118 | + | Self::IncompleteExpired | Self::PastDue | Self::Unpaid => BadgeStatus::Failed, | |
| 119 | + | Self::Canceled => BadgeStatus::Ended, | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // --- Project Pricing --- | |
| 125 | + | ||
| 126 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] | |
| 127 | + | #[serde(rename_all = "snake_case")] | |
| 128 | + | pub enum PricingKind { | |
| 129 | + | #[default] | |
| 130 | + | Free, | |
| 131 | + | BuyOnce, | |
| 132 | + | Pwyw, | |
| 133 | + | Subscription, | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | impl_str_enum!(PricingKind { | |
| 137 | + | Free => "free", | |
| 138 | + | BuyOnce => "buy_once", | |
| 139 | + | Pwyw => "pwyw", | |
| 140 | + | Subscription => "subscription", | |
| 141 | + | }); | |
| 142 | + | ||
| 143 | + | /// Discriminator for checkout session types stored in Stripe metadata. | |
| 144 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 145 | + | #[serde(rename_all = "snake_case")] | |
| 146 | + | pub enum CheckoutType { | |
| 147 | + | Guest, | |
| 148 | + | Subscription, | |
| 149 | + | Tip, | |
| 150 | + | FanPlus, | |
| 151 | + | CreatorTier, | |
| 152 | + | Cart, | |
| 153 | + | SynckitAppSub, | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | impl_str_enum!(CheckoutType { | |
| 157 | + | Guest => "guest", | |
| 158 | + | Subscription => "subscription", | |
| 159 | + | Tip => "tip", | |
| 160 | + | FanPlus => "fan_plus", | |
| 161 | + | CreatorTier => "creator_tier", | |
| 162 | + | Cart => "cart", | |
| 163 | + | SynckitAppSub => "synckit_app_sub", | |
| 164 | + | }); |
| @@ -1,0 +1,73 @@ | |||
| 1 | + | //! The git side: repository visibility and kind, issue state, build state. | |
| 2 | + | ||
| 3 | + | use super::str_enum::impl_str_enum; | |
| 4 | + | use serde::{Deserialize, Serialize}; | |
| 5 | + | ||
| 6 | + | // --- Git repository visibility --- | |
| 7 | + | ||
| 8 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 9 | + | #[serde(rename_all = "lowercase")] | |
| 10 | + | pub enum Visibility { | |
| 11 | + | Public, | |
| 12 | + | Unlisted, | |
| 13 | + | Private, | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | impl_str_enum!(Visibility { | |
| 17 | + | Public => "public", | |
| 18 | + | Unlisted => "unlisted", | |
| 19 | + | Private => "private", | |
| 20 | + | }); | |
| 21 | + | ||
| 22 | + | // --- Git repository kind --- | |
| 23 | + | ||
| 24 | + | /// What a repository is for. | |
| 25 | + | /// | |
| 26 | + | /// `Source` is every repository a creator makes. `Annotations` is the one | |
| 27 | + | /// per-account repository holding nothing but `refs/notes/*`, which is private | |
| 28 | + | /// permanently. | |
| 29 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 30 | + | #[serde(rename_all = "lowercase")] | |
| 31 | + | pub enum GitRepoKind { | |
| 32 | + | Source, | |
| 33 | + | Annotations, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | impl_str_enum!(GitRepoKind { | |
| 37 | + | Source => "source", | |
| 38 | + | Annotations => "annotations", | |
| 39 | + | }); | |
| 40 | + | ||
| 41 | + | // --- Git Issues --- | |
| 42 | + | ||
| 43 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 44 | + | #[serde(rename_all = "lowercase")] | |
| 45 | + | pub enum IssueStatus { | |
| 46 | + | Open, | |
| 47 | + | Closed, | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | impl_str_enum!(IssueStatus { | |
| 51 | + | Open => "open", | |
| 52 | + | Closed => "closed", | |
| 53 | + | }); | |
| 54 | + | ||
| 55 | + | // --- Build Pipeline --- | |
| 56 | + | ||
| 57 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 58 | + | #[serde(rename_all = "snake_case")] | |
| 59 | + | pub enum BuildStatus { | |
| 60 | + | Pending, | |
| 61 | + | Running, | |
| 62 | + | Succeeded, | |
| 63 | + | Failed, | |
| 64 | + | Cancelled, | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | impl_str_enum!(BuildStatus { | |
| 68 | + | Pending => "pending", | |
| 69 | + | Running => "running", | |
| 70 | + | Succeeded => "succeeded", | |
| 71 | + | Failed => "failed", | |
| 72 | + | Cancelled => "cancelled", | |
| 73 | + | }); |
| @@ -1,0 +1,59 @@ | |||
| 1 | + | //! Bringing a catalog in from somewhere else: where it came from and how far | |
| 2 | + | //! the job got. | |
| 3 | + | ||
| 4 | + | use super::str_enum::impl_str_enum; | |
| 5 | + | use serde::{Deserialize, Serialize}; | |
| 6 | + | ||
| 7 | + | // --- Import System --- | |
| 8 | + | ||
| 9 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 10 | + | #[serde(rename_all = "snake_case")] | |
| 11 | + | pub enum ImportSource { | |
| 12 | + | GenericCsv, | |
| 13 | + | Substack, | |
| 14 | + | Ghost, | |
| 15 | + | Gumroad, | |
| 16 | + | Bandcamp, | |
| 17 | + | LemonSqueezy, | |
| 18 | + | Patreon, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | impl_str_enum!(ImportSource { | |
| 22 | + | GenericCsv => "generic_csv", | |
| 23 | + | Substack => "substack", | |
| 24 | + | Ghost => "ghost", | |
| 25 | + | Gumroad => "gumroad", | |
| 26 | + | Bandcamp => "bandcamp", | |
| 27 | + | LemonSqueezy => "lemon_squeezy", | |
| 28 | + | Patreon => "patreon", | |
| 29 | + | }); | |
| 30 | + | ||
| 31 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 32 | + | #[serde(rename_all = "lowercase")] | |
| 33 | + | pub enum ImportJobStatus { | |
| 34 | + | Pending, | |
| 35 | + | Processing, | |
| 36 | + | Completed, | |
| 37 | + | Failed, | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | impl_str_enum!(ImportJobStatus { | |
| 41 | + | Pending => "pending", | |
| 42 | + | Processing => "processing", | |
| 43 | + | Completed => "completed", | |
| 44 | + | Failed => "failed", | |
| 45 | + | }); | |
| 46 | + | ||
| 47 | + | impl ImportJobStatus { | |
| 48 | + | /// Badge vocabulary (charter: `docs/design-system.md`). | |
| 49 | + | pub fn badge_status(self) -> crate::types::BadgeStatus { | |
| 50 | + | use crate::types::BadgeStatus; | |
| 51 | + | match self { | |
| 52 | + | Self::Completed => BadgeStatus::Live, | |
| 53 | + | Self::Pending | Self::Processing => BadgeStatus::Pending, | |
| 54 | + | Self::Failed => BadgeStatus::Failed, | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // Moderation action types |
| @@ -1,0 +1,225 @@ | |||
| 1 | + | //! Mailing lists: what a list is attached to, what it carries, and every state | |
| 2 | + | //! a subscription to one can be in. | |
| 3 | + | ||
| 4 | + | use super::str_enum::impl_str_enum; | |
| 5 | + | use serde::{Deserialize, Serialize}; | |
| 6 | + | ||
| 7 | + | // --- Mailing Lists --- | |
| 8 | + | ||
| 9 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 10 | + | #[serde(rename_all = "lowercase")] | |
| 11 | + | pub enum MailingListType { | |
| 12 | + | Content, | |
| 13 | + | Devlog, | |
| 14 | + | Patches, | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | impl_str_enum!(MailingListType { | |
| 18 | + | Content => "content", | |
| 19 | + | Devlog => "devlog", | |
| 20 | + | Patches => "patches", | |
| 21 | + | }); | |
| 22 | + | ||
| 23 | + | // --- Mailing lists (wiki: mnw-mailing-lists) --- | |
| 24 | + | ||
| 25 | + | /// The legacy per-project list types map onto the unified kinds one-for-one. | |
| 26 | + | /// Kept as a conversion rather than merging the two enums, because | |
| 27 | + | /// `MailingListType` is pinned by a CHECK on the old table and will be dropped | |
| 28 | + | /// with it rather than grown. | |
| 29 | + | impl From<MailingListType> for ListKind { | |
| 30 | + | fn from(t: MailingListType) -> Self { | |
| 31 | + | match t { | |
| 32 | + | MailingListType::Content => Self::Content, | |
| 33 | + | MailingListType::Devlog => Self::Devlog, | |
| 34 | + | MailingListType::Patches => Self::Patches, | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | /// What a list is attached to. `Platform` lists have no `scope_id`; every other | |
| 40 | + | /// scope requires one, and the database enforces the pairing. | |
| 41 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 42 | + | pub enum ListScope { | |
| 43 | + | Platform, | |
| 44 | + | Project, | |
| 45 | + | Repo, | |
| 46 | + | Creator, | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | impl_str_enum!(ListScope { | |
| 50 | + | Platform => "platform", | |
| 51 | + | Project => "project", | |
| 52 | + | Repo => "repo", | |
| 53 | + | Creator => "creator", | |
| 54 | + | }); | |
| 55 | + | ||
| 56 | + | /// What the list carries. Kinds are deliberately coarse: the unsubscribe page | |
| 57 | + | /// shows one row per list, and a subscriber who has to reason about fifteen | |
| 58 | + | /// near-identical kinds will unsubscribe from all of them. | |
| 59 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 60 | + | pub enum ListKind { | |
| 61 | + | Content, | |
| 62 | + | Devlog, | |
| 63 | + | Patches, | |
| 64 | + | Releases, | |
| 65 | + | Issues, | |
| 66 | + | Announce, | |
| 67 | + | Marketing, | |
| 68 | + | // Account notification preferences, each mirroring a users.notify_* column. | |
| 69 | + | // Releases and Issues above double as these at platform scope; the rest are | |
| 70 | + | // their own kinds. | |
| 71 | + | Sale, | |
| 72 | + | Follower, | |
| 73 | + | Login, | |
| 74 | + | Status, | |
| 75 | + | Tip, | |
| 76 | + | // Someone redeemed one of your invite codes. Added by migration 195, when | |
| 77 | + | // that notice stopped being mail you could not turn off. | |
| 78 | + | Invite, | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | impl_str_enum!(ListKind { | |
| 82 | + | Content => "content", | |
| 83 | + | Devlog => "devlog", | |
| 84 | + | Patches => "patches", | |
| 85 | + | Releases => "releases", | |
| 86 | + | Issues => "issues", | |
| 87 | + | Announce => "announce", | |
| 88 | + | Marketing => "marketing", | |
| 89 | + | Sale => "sale", | |
| 90 | + | Follower => "follower", | |
| 91 | + | Login => "login", | |
| 92 | + | Status => "status", | |
| 93 | + | Tip => "tip", | |
| 94 | + | Invite => "invite", | |
| 95 | + | }); | |
| 96 | + | ||
| 97 | + | impl ListKind { | |
| 98 | + | /// Every kind. Exists so a test can assert the enum against the | |
| 99 | + | /// `lists_kind_check` constraint: a kind lives in two places, and adding it | |
| 100 | + | /// to only one fails at INSERT on a deployed database rather than at | |
| 101 | + | /// compile time here. | |
| 102 | + | pub const ALL: &'static [ListKind] = &[ | |
| 103 | + | ListKind::Content, | |
| 104 | + | ListKind::Devlog, | |
| 105 | + | ListKind::Patches, | |
| 106 | + | ListKind::Releases, | |
| 107 | + | ListKind::Issues, | |
| 108 | + | ListKind::Announce, | |
| 109 | + | ListKind::Marketing, | |
| 110 | + | ListKind::Sale, | |
| 111 | + | ListKind::Follower, | |
| 112 | + | ListKind::Login, | |
| 113 | + | ListKind::Status, | |
| 114 | + | ListKind::Tip, | |
| 115 | + | ListKind::Invite, | |
| 116 | + | ]; | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | /// What is waiting to be acknowledged. | |
| 120 | + | /// | |
| 121 | + | /// Closed and asserted against the `pending_acknowledgements.kind` CHECK | |
| 122 | + | /// constraint by a test in `db::acknowledgements`: a variant added here without | |
| 123 | + | /// a migration compiles, passes every unit test, and then fails at INSERT on a | |
| 124 | + | /// deployed database. | |
| 125 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 126 | + | pub enum AckKind { | |
| 127 | + | /// The creator's Stripe account changed settlement currency, so every price | |
| 128 | + | /// they have already set is now a number denominated in different money. | |
| 129 | + | SettlementCurrencyChanged, | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | impl_str_enum!(AckKind { | |
| 133 | + | SettlementCurrencyChanged => "settlement_currency_changed", | |
| 134 | + | }); | |
| 135 | + | ||
| 136 | + | impl AckKind { | |
| 137 | + | pub const ALL: &'static [AckKind] = &[AckKind::SettlementCurrencyChanged]; | |
| 138 | + | ||
| 139 | + | /// Subject line and page heading, one source for both. | |
| 140 | + | pub fn title(self) -> &'static str { | |
| 141 | + | match self { | |
| 142 | + | Self::SettlementCurrencyChanged => "Your prices are now in a different currency", | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | /// Where a subscription stands. | |
| 148 | + | /// | |
| 149 | + | /// `Imported` is its own state on purpose. Everything the step-2 backfill | |
| 150 | + | /// carried over predates any consent record: calling it `Confirmed` would | |
| 151 | + | /// manufacture evidence we do not have, and calling it `Pending` would assert a | |
| 152 | + | /// double-opt-in is in flight when none is. The state says exactly what is | |
| 153 | + | /// known and no more. | |
| 154 | + | /// | |
| 155 | + | /// It is provenance, not a quarantine. An imported subscriber may be mailed: | |
| 156 | + | /// sendable, marketing included, and no re-confirmation pass is coming. See | |
| 157 | + | /// `db::lists::SENDABLE_STATES` for the reasoning. The | |
| 158 | + | /// state stays distinct because "we carried this over" is worth being able to | |
| 159 | + | /// say years later, not because anything gates on it. | |
| 160 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 161 | + | pub enum SubscriptionState { | |
| 162 | + | Pending, | |
| 163 | + | Confirmed, | |
| 164 | + | Imported, | |
| 165 | + | Unsubscribed, | |
| 166 | + | Bounced, | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | impl_str_enum!(SubscriptionState { | |
| 170 | + | Pending => "pending", | |
| 171 | + | Confirmed => "confirmed", | |
| 172 | + | Imported => "imported", | |
| 173 | + | Unsubscribed => "unsubscribed", | |
| 174 | + | Bounced => "bounced", | |
| 175 | + | }); | |
| 176 | + | ||
| 177 | + | /// How the subscription was created, which the table this replaces could not | |
| 178 | + | /// say. | |
| 179 | + | /// | |
| 180 | + | /// Recorded for a re-confirmation pass that was then decided against (GoingsOn | |
| 181 | + | /// 04a882b4). It stays because "where did this address come from" is the first | |
| 182 | + | /// question asked of a complaint, and answering it is worth one column whether | |
| 183 | + | /// or not anything ever filters on it. | |
| 184 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 185 | + | pub enum SubscriptionSource { | |
| 186 | + | LandingForm, | |
| 187 | + | ProjectPage, | |
| 188 | + | Checkout, | |
| 189 | + | Import, | |
| 190 | + | Admin, | |
| 191 | + | Api, | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | impl_str_enum!(SubscriptionSource { | |
| 195 | + | LandingForm => "landing_form", | |
| 196 | + | ProjectPage => "project_page", | |
| 197 | + | Checkout => "checkout", | |
| 198 | + | Import => "import", | |
| 199 | + | Admin => "admin", | |
| 200 | + | Api => "api", | |
| 201 | + | }); | |
| 202 | + | ||
| 203 | + | /// An entry in a subscription's consent history. The table is append-only: an | |
| 204 | + | /// opt-out adds a row rather than editing the opt-in that came before, and a | |
| 205 | + | /// database trigger rejects `UPDATE` so that stays true. | |
| 206 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 207 | + | pub enum ConsentEvent { | |
| 208 | + | OptIn, | |
| 209 | + | Confirm, | |
| 210 | + | OptOut, | |
| 211 | + | Bounce, | |
| 212 | + | Complaint, | |
| 213 | + | AdminRemoval, | |
| 214 | + | Import, | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | impl_str_enum!(ConsentEvent { | |
| 218 | + | OptIn => "opt_in", | |
| 219 | + | Confirm => "confirm", | |
| 220 | + | OptOut => "opt_out", | |
| 221 | + | Bounce => "bounce", | |
| 222 | + | Complaint => "complaint", | |
| 223 | + | AdminRemoval => "admin_removal", | |
| 224 | + | Import => "import", | |
| 225 | + | }); |
| @@ -1,0 +1,31 @@ | |||
| 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 | + | //! Grouped by domain, one module per group, with the shared `impl_str_enum!` | |
| 8 | + | //! macro in `str_enum`. Every group is re-exported flat, so callers name | |
| 9 | + | //! `crate::db::ItemType` exactly as before. | |
| 10 | + | ||
| 11 | + | mod accounts; | |
| 12 | + | mod catalog; | |
| 13 | + | mod commerce; | |
| 14 | + | mod git; | |
| 15 | + | mod imports; | |
| 16 | + | mod mail; | |
| 17 | + | mod scanning; | |
| 18 | + | mod str_enum; | |
| 19 | + | mod synckit; | |
| 20 | + | ||
| 21 | + | pub use accounts::*; | |
| 22 | + | pub use catalog::*; | |
| 23 | + | pub use commerce::*; | |
| 24 | + | pub use git::*; | |
| 25 | + | pub use imports::*; | |
| 26 | + | pub use mail::*; | |
| 27 | + | pub use scanning::*; | |
| 28 | + | pub use synckit::*; | |
| 29 | + | ||
| 30 | + | #[cfg(test)] | |
| 31 | + | mod tests; |
| @@ -1,0 +1,38 @@ | |||
| 1 | + | //! Where an uploaded file is in the malware scan. | |
| 2 | + | ||
| 3 | + | use super::str_enum::impl_str_enum; | |
| 4 | + | use serde::{Deserialize, Serialize}; | |
| 5 | + | ||
| 6 | + | // --- File scanning --- | |
| 7 | + | ||
| 8 | + | /// Status of an uploaded file in the scan pipeline. | |
| 9 | + | /// | |
| 10 | + | /// `Pending`, accepted, waiting in `scan_jobs` queue for a worker. | |
| 11 | + | /// `Scanning`, worker has claimed the job and is running the pipeline. | |
| 12 | + | /// `Clean`, pipeline completed, no Fail verdicts, no fail-closed Errors. | |
| 13 | + | /// `HeldForReview`, pipeline completed with a fail-closed Error, OR the | |
| 14 | + | /// uploader is untrusted (every untrusted upload routes to admin review). | |
| 15 | + | /// `Quarantined`, pipeline returned a Fail verdict on at least one layer. | |
| 16 | + | /// `Error`, pipeline itself crashed (worker exception, S3 fetch failed, etc.). | |
| 17 | + | /// | |
| 18 | + | /// Transitions are driven by `crate::scanning::final_status` and applied by | |
| 19 | + | /// `crate::scanning::worker`; `Pending` is the only entry state. | |
| 20 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 21 | + | #[serde(rename_all = "snake_case")] | |
| 22 | + | pub enum FileScanStatus { | |
| 23 | + | Pending, | |
| 24 | + | Scanning, | |
| 25 | + | Clean, | |
| 26 | + | Quarantined, | |
| 27 | + | HeldForReview, | |
| 28 | + | Error, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | impl_str_enum!(FileScanStatus { | |
| 32 | + | Pending => "pending", | |
| 33 | + | Scanning => "scanning", | |
| 34 | + | Clean => "clean", | |
| 35 | + | Quarantined => "quarantined", | |
| 36 | + | HeldForReview => "held_for_review", | |
| 37 | + | Error => "error", | |
| 38 | + | }); |
| @@ -1,0 +1,97 @@ | |||
| 1 | + | //! The one macro every enum here is built with. | |
| 2 | + | //! | |
| 3 | + | //! `macro_rules!` is textually scoped, so a sibling cannot see it by being a | |
| 4 | + | //! sibling: it reaches them through the `pub(super) use` at the bottom of this | |
| 5 | + | //! file, and each sibling imports it by name. | |
| 6 | + | ||
| 7 | + | /// Generate `Display`, `FromStr`, and sqlx `Type`/`Encode`/`Decode` impls | |
| 8 | + | /// for a simple enum ↔ string mapping. The sqlx impls delegate to `String` | |
| 9 | + | /// so the enum is compatible with any text-like column (TEXT, VARCHAR, etc.). | |
| 10 | + | macro_rules! impl_str_enum { | |
| 11 | + | ($enum_name:ident { $($variant:ident => $str:literal),+ $(,)? }) => { | |
| 12 | + | impl std::fmt::Display for $enum_name { | |
| 13 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 14 | + | let s = match self { | |
| 15 | + | $( Self::$variant => $str, )+ | |
| 16 | + | }; | |
| 17 | + | f.write_str(s) | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | impl std::str::FromStr for $enum_name { | |
| 22 | + | type Err = String; | |
| 23 | + | ||
| 24 | + | fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { | |
| 25 | + | match s { | |
| 26 | + | $( $str => Ok(Self::$variant), )+ | |
| 27 | + | other => Err(format!("invalid {}: {other}", stringify!($enum_name))), | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // sqlx Type: delegate to String so it's compatible with TEXT/VARCHAR. | |
| 33 | + | impl sqlx::Type<sqlx::Postgres> for $enum_name { | |
| 34 | + | fn type_info() -> sqlx::postgres::PgTypeInfo { | |
| 35 | + | <String as sqlx::Type<sqlx::Postgres>>::type_info() | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { | |
| 39 | + | <String as sqlx::Type<sqlx::Postgres>>::compatible(ty) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // sqlx Encode: write the Display string. | |
| 44 | + | impl sqlx::Encode<'_, sqlx::Postgres> for $enum_name { | |
| 45 | + | fn encode_by_ref( | |
| 46 | + | &self, | |
| 47 | + | buf: &mut sqlx::postgres::PgArgumentBuffer, | |
| 48 | + | ) -> Result<sqlx::encode::IsNull, Box<dyn std::error::Error + Send + Sync>> { | |
| 49 | + | <String as sqlx::Encode<'_, sqlx::Postgres>>::encode(self.to_string(), buf) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // sqlx Decode: parse the string value via FromStr. | |
| 54 | + | impl sqlx::Decode<'_, sqlx::Postgres> for $enum_name { | |
| 55 | + | fn decode( | |
| 56 | + | value: sqlx::postgres::PgValueRef<'_>, | |
| 57 | + | ) -> std::result::Result<Self, Box<dyn std::error::Error + Send + Sync>> { | |
| 58 | + | let s = <String as sqlx::Decode<'_, sqlx::Postgres>>::decode(value)?; | |
| 59 | + | Ok(s.parse::<Self>()?) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // Allow comparison with string slices (useful in Askama templates). | |
| 64 | + | impl PartialEq<&str> for $enum_name { | |
| 65 | + | fn eq(&self, other: &&str) -> bool { | |
| 66 | + | let s: &str = match self { | |
| 67 | + | $( Self::$variant => $str, )+ | |
| 68 | + | }; | |
| 69 | + | s == *other | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | impl PartialEq<str> for $enum_name { | |
| 74 | + | fn eq(&self, other: &str) -> bool { | |
| 75 | + | let s: &str = match self { | |
| 76 | + | $( Self::$variant => $str, )+ | |
| 77 | + | }; | |
| 78 | + | s == other | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | impl $enum_name { | |
| 83 | + | /// Every wire/DB string this enum maps to, the single source of | |
| 84 | + | /// truth for the variant set. For each enum *registered* in the | |
| 85 | + | /// enum-drift integration test (`tests/workflows/enum_drift.rs`), | |
| 86 | + | /// this set is asserted equal to the Postgres `CHECK (... IN (...))` | |
| 87 | + | /// list on its backing column, so a variant added here without | |
| 88 | + | /// widening the DB constraint (or vice versa) fails at test time | |
| 89 | + | /// rather than at the first read of a poisoned row. Coverage is that | |
| 90 | + | /// registry, not every enum automatically: add a `(enum, table, | |
| 91 | + | /// column)` row there when a new CHECK-constrained column lands. | |
| 92 | + | pub const VARIANTS: &'static [&'static str] = &[$($str),+]; | |
| 93 | + | } | |
| 94 | + | }; | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | pub(super) use impl_str_enum; |
| @@ -1,0 +1,86 @@ | |||
| 1 | + | //! SyncKit developer billing and the sync traffic it meters. | |
| 2 | + | ||
| 3 | + | use super::str_enum::impl_str_enum; | |
| 4 | + | use serde::{Deserialize, Serialize}; | |
| 5 | + | ||
| 6 | + | // --- SyncKit developer billing --- | |
| 7 | + | ||
| 8 | + | /// Lifecycle of a SyncKit developer app's billing record (the `sync_apps.billing_status` | |
| 9 | + | /// TEXT column, CHECK-constrained in migration 117). Replaces the raw string the | |
| 10 | + | /// `DbSyncAppBilling` model used to carry, so a status comparison can't drift from the | |
| 11 | + | /// CHECK set. | |
| 12 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 13 | + | pub enum SyncBillingStatus { | |
| 14 | + | #[serde(rename = "draft")] | |
| 15 | + | Draft, | |
| 16 | + | #[serde(rename = "active")] | |
| 17 | + | Active, | |
| 18 | + | #[serde(rename = "suspended_unpaid")] | |
| 19 | + | SuspendedUnpaid, | |
| 20 | + | #[serde(rename = "canceled")] | |
| 21 | + | Canceled, | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | impl_str_enum!(SyncBillingStatus { | |
| 25 | + | Draft => "draft", | |
| 26 | + | Active => "active", | |
| 27 | + | SuspendedUnpaid => "suspended_unpaid", | |
| 28 | + | Canceled => "canceled", | |
| 29 | + | }); | |
| 30 | + | ||
| 31 | + | /// How a SyncKit developer app's storage billing is enforced (the | |
| 32 | + | /// `sync_apps.enforcement_mode` TEXT column, CHECK-constrained to `('per_key','bulk')` | |
| 33 | + | /// in migration 118). Replaces the raw string the `DbSyncAppBilling` model used to | |
| 34 | + | /// carry. Lifting this to an enum makes `monthly_price_cents` match exhaustively, so an | |
| 35 | + | /// unrecognized mode is no longer silently priced at the floor (Pay-S2). The historical | |
| 36 | + | /// `app_wide` value was renamed to `bulk` in migration 118; only these two are live. | |
| 37 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 38 | + | pub enum SyncEnforcementMode { | |
| 39 | + | #[serde(rename = "per_key")] | |
| 40 | + | PerKey, | |
| 41 | + | #[serde(rename = "bulk")] | |
| 42 | + | Bulk, | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | impl_str_enum!(SyncEnforcementMode { | |
| 46 | + | PerKey => "per_key", | |
| 47 | + | Bulk => "bulk", | |
| 48 | + | }); | |
| 49 | + | ||
| 50 | + | // --- SyncKit --- | |
| 51 | + | ||
| 52 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 53 | + | pub enum SyncOperation { | |
| 54 | + | #[serde(rename = "INSERT")] | |
| 55 | + | Insert, | |
| 56 | + | #[serde(rename = "UPDATE")] | |
| 57 | + | Update, | |
| 58 | + | #[serde(rename = "DELETE")] | |
| 59 | + | Delete, | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | impl_str_enum!(SyncOperation { | |
| 63 | + | Insert => "INSERT", | |
| 64 | + | Update => "UPDATE", | |
| 65 | + | Delete => "DELETE", | |
| 66 | + | }); | |
| 67 | + | ||
| 68 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 69 | + | #[serde(rename_all = "lowercase")] | |
| 70 | + | pub enum SyncPlatform { | |
| 71 | + | Macos, | |
| 72 | + | Ios, | |
| 73 | + | Android, | |
| 74 | + | Windows, | |
| 75 | + | Linux, | |
| 76 | + | Web, | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | impl_str_enum!(SyncPlatform { | |
| 80 | + | Macos => "macos", | |
| 81 | + | Ios => "ios", | |
| 82 | + | Android => "android", | |
| 83 | + | Windows => "windows", | |
| 84 | + | Linux => "linux", | |
| 85 | + | Web => "web", | |
| 86 | + | }); |
| @@ -1,0 +1,857 @@ | |||
| 1 | + | //! Tests for [`super`]. | |
| 2 | + | ||
| 3 | + | use super::*; | |
| 4 | + | ||
| 5 | + | #[test] | |
| 6 | + | fn discount_type_round_trip() { | |
| 7 | + | assert_eq!(DiscountType::Percentage.to_string(), "percentage"); | |
| 8 | + | assert_eq!( | |
| 9 | + | "fixed".parse::<DiscountType>().unwrap(), | |
| 10 | + | DiscountType::Fixed | |
| 11 | + | ); | |
| 12 | + | assert!("bogus".parse::<DiscountType>().is_err()); | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | #[test] | |
| 16 | + | fn waitlist_status_round_trip() { | |
| 17 | + | assert_eq!(WaitlistStatus::Pending.to_string(), "pending"); | |
| 18 | + | assert_eq!( | |
| 19 | + | "approved".parse::<WaitlistStatus>().unwrap(), | |
| 20 | + | WaitlistStatus::Approved | |
| 21 | + | ); | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | #[test] | |
| 25 | + | fn selection_method_round_trip() { | |
| 26 | + | assert_eq!(SelectionMethod::HandPicked.to_string(), "hand_picked"); | |
| 27 | + | assert_eq!( | |
| 28 | + | "lottery".parse::<SelectionMethod>().unwrap(), | |
| 29 | + | SelectionMethod::Lottery | |
| 30 | + | ); | |
| 31 | + | assert_eq!(SelectionMethod::Invited.to_string(), "invited"); | |
| 32 | + | assert_eq!( | |
| 33 | + | "invited".parse::<SelectionMethod>().unwrap(), | |
| 34 | + | SelectionMethod::Invited | |
| 35 | + | ); | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | #[test] | |
| 39 | + | fn transaction_status_round_trip() { | |
| 40 | + | assert_eq!(TransactionStatus::Completed.to_string(), "completed"); | |
| 41 | + | assert_eq!( | |
| 42 | + | "refunded".parse::<TransactionStatus>().unwrap(), | |
| 43 | + | TransactionStatus::Refunded | |
| 44 | + | ); | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | #[test] | |
| 48 | + | fn follow_target_type_round_trip() { | |
| 49 | + | assert_eq!(FollowTargetType::User.to_string(), "user"); | |
| 50 | + | assert_eq!( | |
| 51 | + | "tag".parse::<FollowTargetType>().unwrap(), | |
| 52 | + | FollowTargetType::Tag | |
| 53 | + | ); | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | #[test] | |
| 57 | + | fn subscription_status_round_trip() { | |
| 58 | + | assert_eq!(SubscriptionStatus::PastDue.to_string(), "past_due"); | |
| 59 | + | assert_eq!( | |
| 60 | + | "canceled".parse::<SubscriptionStatus>().unwrap(), | |
| 61 | + | SubscriptionStatus::Canceled | |
| 62 | + | ); | |
| 63 | + | assert_eq!(SubscriptionStatus::Trialing.to_string(), "trialing"); | |
| 64 | + | assert_eq!( | |
| 65 | + | "trialing".parse::<SubscriptionStatus>().unwrap(), | |
| 66 | + | SubscriptionStatus::Trialing | |
| 67 | + | ); | |
| 68 | + | assert_eq!(SubscriptionStatus::Incomplete.to_string(), "incomplete"); | |
| 69 | + | assert_eq!( | |
| 70 | + | "incomplete".parse::<SubscriptionStatus>().unwrap(), | |
| 71 | + | SubscriptionStatus::Incomplete | |
| 72 | + | ); | |
| 73 | + | assert_eq!( | |
| 74 | + | SubscriptionStatus::IncompleteExpired.to_string(), | |
| 75 | + | "incomplete_expired" | |
| 76 | + | ); | |
| 77 | + | assert_eq!( | |
| 78 | + | "incomplete_expired".parse::<SubscriptionStatus>().unwrap(), | |
| 79 | + | SubscriptionStatus::IncompleteExpired | |
| 80 | + | ); | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | #[test] | |
| 84 | + | fn sync_operation_round_trip() { | |
| 85 | + | assert_eq!(SyncOperation::Insert.to_string(), "INSERT"); | |
| 86 | + | assert_eq!( | |
| 87 | + | "DELETE".parse::<SyncOperation>().unwrap(), | |
| 88 | + | SyncOperation::Delete | |
| 89 | + | ); | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | #[test] | |
| 93 | + | fn sync_platform_round_trip() { | |
| 94 | + | assert_eq!(SyncPlatform::Macos.to_string(), "macos"); | |
| 95 | + | assert_eq!("web".parse::<SyncPlatform>().unwrap(), SyncPlatform::Web); | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | #[test] | |
| 99 | + | fn item_type_round_trip() { | |
| 100 | + | assert_eq!(ItemType::Audio.to_string(), "audio"); | |
| 101 | + | assert_eq!("plugin".parse::<ItemType>().unwrap(), ItemType::Plugin); | |
| 102 | + | assert_eq!(ItemType::Bundle.to_string(), "bundle"); | |
| 103 | + | assert_eq!("bundle".parse::<ItemType>().unwrap(), ItemType::Bundle); | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | #[test] | |
| 107 | + | fn insertion_position_round_trip() { | |
| 108 | + | assert_eq!(InsertionPosition::PreRoll.to_string(), "pre_roll"); | |
| 109 | + | assert_eq!( | |
| 110 | + | "mid_roll".parse::<InsertionPosition>().unwrap(), | |
| 111 | + | InsertionPosition::MidRoll | |
| 112 | + | ); | |
| 113 | + | assert_eq!( | |
| 114 | + | "post_roll".parse::<InsertionPosition>().unwrap(), | |
| 115 | + | InsertionPosition::PostRoll | |
| 116 | + | ); | |
| 117 | + | assert!("invalid".parse::<InsertionPosition>().is_err()); | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | #[test] | |
| 121 | + | fn item_type_label() { | |
| 122 | + | assert_eq!(ItemType::Audio.label(), "Audio"); | |
| 123 | + | assert_eq!(ItemType::Plugin.label(), "Plugin"); | |
| 124 | + | assert_eq!(ItemType::Template.label(), "Template"); | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | #[test] | |
| 128 | + | fn appeal_decision_round_trip() { | |
| 129 | + | assert_eq!(AppealDecision::Approved.to_string(), "approved"); | |
| 130 | + | assert_eq!( | |
| 131 | + | "denied".parse::<AppealDecision>().unwrap(), | |
| 132 | + | AppealDecision::Denied | |
| 133 | + | ); | |
| 134 | + | assert!("bogus".parse::<AppealDecision>().is_err()); | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | #[test] | |
| 138 | + | fn discover_sort_round_trip() { | |
| 139 | + | assert_eq!(DiscoverSort::Newest.to_string(), "newest"); | |
| 140 | + | assert_eq!( | |
| 141 | + | "most_sold".parse::<DiscoverSort>().unwrap(), | |
| 142 | + | DiscoverSort::MostSold | |
| 143 | + | ); | |
| 144 | + | assert_eq!( | |
| 145 | + | "price_asc".parse::<DiscoverSort>().unwrap(), | |
| 146 | + | DiscoverSort::PriceAsc | |
| 147 | + | ); | |
| 148 | + | assert_eq!( | |
| 149 | + | "price_desc".parse::<DiscoverSort>().unwrap(), | |
| 150 | + | DiscoverSort::PriceDesc | |
| 151 | + | ); | |
| 152 | + | assert!("invalid".parse::<DiscoverSort>().is_err()); | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | #[test] | |
| 156 | + | fn file_scan_status_round_trip() { | |
| 157 | + | assert_eq!(FileScanStatus::Clean.to_string(), "clean"); | |
| 158 | + | assert_eq!(FileScanStatus::Pending.to_string(), "pending"); | |
| 159 | + | assert_eq!(FileScanStatus::Scanning.to_string(), "scanning"); | |
| 160 | + | assert_eq!( | |
| 161 | + | "pending".parse::<FileScanStatus>().unwrap(), | |
| 162 | + | FileScanStatus::Pending | |
| 163 | + | ); | |
| 164 | + | assert_eq!( | |
| 165 | + | "scanning".parse::<FileScanStatus>().unwrap(), | |
| 166 | + | FileScanStatus::Scanning | |
| 167 | + | ); | |
| 168 | + | assert_eq!( | |
| 169 | + | "held_for_review".parse::<FileScanStatus>().unwrap(), | |
| 170 | + | FileScanStatus::HeldForReview | |
| 171 | + | ); | |
| 172 | + | assert_eq!(FileScanStatus::HeldForReview.to_string(), "held_for_review"); | |
| 173 | + | assert_eq!( | |
| 174 | + | "quarantined".parse::<FileScanStatus>().unwrap(), | |
| 175 | + | FileScanStatus::Quarantined | |
| 176 | + | ); | |
| 177 | + | assert!("bogus".parse::<FileScanStatus>().is_err()); | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | #[test] | |
| 181 | + | fn code_purpose_round_trip() { | |
| 182 | + | assert_eq!(CodePurpose::Discount.to_string(), "discount"); | |
| 183 | + | assert_eq!( | |
| 184 | + | "free_access".parse::<CodePurpose>().unwrap(), | |
| 185 | + | CodePurpose::FreeAccess | |
| 186 | + | ); | |
| 187 | + | assert_eq!( | |
| 188 | + | "free_trial".parse::<CodePurpose>().unwrap(), | |
| 189 | + | CodePurpose::FreeTrial | |
| 190 | + | ); | |
| 191 | + | assert!("bogus".parse::<CodePurpose>().is_err()); | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | #[test] | |
| 195 | + | fn issue_status_round_trip() { | |
| 196 | + | assert_eq!(IssueStatus::Open.to_string(), "open"); | |
| 197 | + | assert_eq!( | |
| 198 | + | "closed".parse::<IssueStatus>().unwrap(), | |
| 199 | + | IssueStatus::Closed | |
| 200 | + | ); | |
| 201 | + | assert!("bogus".parse::<IssueStatus>().is_err()); | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | #[test] | |
| 205 | + | fn report_target_type_round_trip() { | |
| 206 | + | assert_eq!(ReportTargetType::Project.to_string(), "project"); | |
| 207 | + | assert_eq!( | |
| 208 | + | "item".parse::<ReportTargetType>().unwrap(), | |
| 209 | + | ReportTargetType::Item | |
| 210 | + | ); | |
| 211 | + | assert!("bogus".parse::<ReportTargetType>().is_err()); | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | #[test] | |
| 215 | + | fn report_type_round_trip() { | |
| 216 | + | assert_eq!(ReportType::Mislabeled.to_string(), "mislabeled"); | |
| 217 | + | assert_eq!("spam".parse::<ReportType>().unwrap(), ReportType::Spam); | |
| 218 | + | assert_eq!("abuse".parse::<ReportType>().unwrap(), ReportType::Abuse); | |
| 219 | + | assert_eq!( | |
| 220 | + | "infringement".parse::<ReportType>().unwrap(), | |
| 221 | + | ReportType::Infringement | |
| 222 | + | ); | |
| 223 | + | assert_eq!("other".parse::<ReportType>().unwrap(), ReportType::Other); | |
| 224 | + | assert!("bogus".parse::<ReportType>().is_err()); | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | #[test] | |
| 228 | + | fn report_status_round_trip() { | |
| 229 | + | assert_eq!(ReportStatus::Open.to_string(), "open"); | |
| 230 | + | assert_eq!( | |
| 231 | + | "resolved".parse::<ReportStatus>().unwrap(), | |
| 232 | + | ReportStatus::Resolved | |
| 233 | + | ); | |
| 234 | + | assert_eq!( | |
| 235 | + | "dismissed".parse::<ReportStatus>().unwrap(), | |
| 236 | + | ReportStatus::Dismissed | |
| 237 | + | ); | |
| 238 | + | assert!("bogus".parse::<ReportStatus>().is_err()); | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | #[test] | |
| 242 | + | fn creator_tier_round_trip() { | |
| 243 | + | assert_eq!(CreatorTier::Basic.to_string(), "basic"); | |
| 244 | + | assert_eq!( | |
| 245 | + | "small_files".parse::<CreatorTier>().unwrap(), | |
| 246 | + | CreatorTier::SmallFiles | |
| 247 | + | ); | |
| 248 | + | assert_eq!( | |
| 249 | + | "big_files".parse::<CreatorTier>().unwrap(), | |
| 250 | + | CreatorTier::BigFiles | |
| 251 | + | ); | |
| 252 | + | assert_eq!( | |
| 253 | + | "everything".parse::<CreatorTier>().unwrap(), | |
| 254 | + | CreatorTier::Everything | |
| 255 | + | ); | |
| 256 | + | assert!("bogus".parse::<CreatorTier>().is_err()); | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | #[test] | |
| 260 | + | fn creator_tier_label_and_price() { | |
| 261 | + | crate::tier_prices::TierPrices::install_test_default(); | |
| 262 | + | assert_eq!(CreatorTier::Basic.label(), "Basic"); | |
| 263 | + | assert_eq!(CreatorTier::SmallFiles.label(), "Small Files"); | |
| 264 | + | // Prices come from assumptions.toml. Assert invariants, founder = std/2, | |
| 265 | + | // monotone across tiers, not literal cents, so a future toml edit doesn't | |
| 266 | + | // break this test. | |
| 267 | + | let tiers = [ | |
| 268 | + | CreatorTier::Basic, | |
| 269 | + | CreatorTier::SmallFiles, | |
| 270 | + | CreatorTier::BigFiles, | |
| 271 | + | CreatorTier::Everything, | |
| 272 | + | ]; | |
| 273 | + | for pair in tiers.windows(2) { | |
| 274 | + | assert!( | |
| 275 | + | pair[0].price_cents() < pair[1].price_cents(), | |
| 276 | + | "{:?} price_cents ({}) should be < {:?} ({})", | |
| 277 | + | pair[0], | |
| 278 | + | pair[0].price_cents(), | |
| 279 | + | pair[1], | |
| 280 | + | pair[1].price_cents(), | |
| 281 | + | ); | |
| 282 | + | } | |
| 283 | + | assert!(CreatorTier::Basic.price_cents() > 0); | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | #[test] | |
| 287 | + | fn creator_tier_file_limits() { | |
| 288 | + | crate::tier_prices::TierPrices::install_test_default(); | |
| 289 | + | // File caps monotone across tiers; Basic ≤ SmallFiles ≤ BigFiles == Everything. | |
| 290 | + | let tiers = [ | |
| 291 | + | CreatorTier::Basic, | |
| 292 | + | CreatorTier::SmallFiles, | |
| 293 | + | CreatorTier::BigFiles, | |
| 294 | + | CreatorTier::Everything, | |
| 295 | + | ]; | |
| 296 | + | for pair in tiers.windows(2) { | |
| 297 | + | assert!( | |
| 298 | + | pair[0].max_file_bytes() <= pair[1].max_file_bytes(), | |
| 299 | + | "{:?} max_file_bytes ({}) should be <= {:?} ({})", | |
| 300 | + | pair[0], | |
| 301 | + | pair[0].max_file_bytes(), | |
| 302 | + | pair[1], | |
| 303 | + | pair[1].max_file_bytes(), | |
| 304 | + | ); | |
| 305 | + | } | |
| 306 | + | assert!(CreatorTier::Basic.max_file_bytes() > 0); | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | #[test] | |
| 310 | + | fn creator_tier_storage_limits() { | |
| 311 | + | crate::tier_prices::TierPrices::install_test_default(); | |
| 312 | + | let tiers = [ | |
| 313 | + | CreatorTier::Basic, | |
| 314 | + | CreatorTier::SmallFiles, | |
| 315 | + | CreatorTier::BigFiles, | |
| 316 | + | CreatorTier::Everything, | |
| 317 | + | ]; | |
| 318 | + | for pair in tiers.windows(2) { | |
| 319 | + | assert!( | |
| 320 | + | pair[0].max_storage_bytes() <= pair[1].max_storage_bytes(), | |
| 321 | + | "{:?} max_storage_bytes ({}) should be <= {:?} ({})", | |
| 322 | + | pair[0], | |
| 323 | + | pair[0].max_storage_bytes(), | |
| 324 | + | pair[1], | |
| 325 | + | pair[1].max_storage_bytes(), | |
| 326 | + | ); | |
| 327 | + | } | |
| 328 | + | assert!(CreatorTier::Basic.max_storage_bytes() > 0); | |
| 329 | + | // Every tier's per-file cap must fit in its total storage cap or uploads | |
| 330 | + | // are impossible. Catches an accidental toml edit that shrinks a total | |
| 331 | + | // below the per-file limit. | |
| 332 | + | for &tier in &tiers { | |
| 333 | + | assert!( | |
| 334 | + | tier.max_file_bytes() <= tier.max_storage_bytes(), | |
| 335 | + | "{tier:?}: file cap ({}) exceeds storage cap ({})", | |
| 336 | + | tier.max_file_bytes(), | |
| 337 | + | tier.max_storage_bytes(), | |
| 338 | + | ); | |
| 339 | + | } | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | #[test] | |
| 343 | + | fn creator_tier_allows_file_uploads() { | |
| 344 | + | assert!(!CreatorTier::Basic.allows_file_uploads()); | |
| 345 | + | assert!(CreatorTier::SmallFiles.allows_file_uploads()); | |
| 346 | + | assert!(CreatorTier::BigFiles.allows_file_uploads()); | |
| 347 | + | assert!(CreatorTier::Everything.allows_file_uploads()); | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | #[test] | |
| 351 | + | fn creator_tier_features_track_live_capabilities() { | |
| 352 | + | assert!(CreatorTier::Basic.features().is_empty()); | |
| 353 | + | assert_eq!(CreatorTier::SmallFiles.features(), &["file_uploads"]); | |
| 354 | + | assert_eq!( | |
| 355 | + | CreatorTier::BigFiles.features(), | |
| 356 | + | &["file_uploads", "large_files"] | |
| 357 | + | ); | |
| 358 | + | assert_eq!( | |
| 359 | + | CreatorTier::Everything.features(), | |
| 360 | + | &["file_uploads", "large_files"] | |
| 361 | + | ); | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | #[test] | |
| 365 | + | fn project_feature_round_trip() { | |
| 366 | + | assert_eq!(ProjectFeature::Audio.to_string(), "audio"); | |
| 367 | + | assert_eq!( | |
| 368 | + | "downloads".parse::<ProjectFeature>().unwrap(), | |
| 369 | + | ProjectFeature::Downloads | |
| 370 | + | ); | |
| 371 | + | assert_eq!( | |
| 372 | + | "license_keys".parse::<ProjectFeature>().unwrap(), | |
| 373 | + | ProjectFeature::LicenseKeys | |
| 374 | + | ); | |
| 375 | + | assert_eq!( | |
| 376 | + | "source_code".parse::<ProjectFeature>().unwrap(), | |
| 377 | + | ProjectFeature::SourceCode | |
| 378 | + | ); | |
| 379 | + | assert!("bogus".parse::<ProjectFeature>().is_err()); | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | #[test] | |
| 383 | + | fn project_feature_label_and_description() { | |
| 384 | + | assert_eq!(ProjectFeature::Audio.label(), "Audio"); | |
| 385 | + | assert_eq!(ProjectFeature::LicenseKeys.label(), "License Keys"); | |
| 386 | + | assert!(!ProjectFeature::Audio.description().is_empty()); | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | #[test] | |
| 390 | + | fn project_feature_all() { | |
| 391 | + | let all = ProjectFeature::all(); | |
| 392 | + | assert_eq!(all.len(), 8); | |
| 393 | + | assert_eq!(all[0].0, "audio"); | |
| 394 | + | assert_eq!(all[7].0, "cloud_sync"); | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | #[test] | |
| 398 | + | fn project_feature_allowed_item_types_audio() { | |
| 399 | + | let types = ProjectFeature::Audio.allowed_item_types(); | |
| 400 | + | assert!(types.contains(&ItemType::Audio)); | |
| 401 | + | assert!(types.contains(&ItemType::Sample)); | |
| 402 | + | assert!(types.contains(&ItemType::Preset)); | |
| 403 | + | assert!(!types.contains(&ItemType::Text)); | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | #[test] | |
| 407 | + | fn project_feature_allowed_item_types_downloads() { | |
| 408 | + | let types = ProjectFeature::Downloads.allowed_item_types(); | |
| 409 | + | assert!(types.contains(&ItemType::Digital)); | |
| 410 | + | assert!(types.contains(&ItemType::Plugin)); | |
| 411 | + | assert!(types.contains(&ItemType::Video)); | |
| 412 | + | assert!(!types.contains(&ItemType::Audio)); | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | #[test] | |
| 416 | + | fn project_feature_allowed_item_types_text() { | |
| 417 | + | let types = ProjectFeature::Text.allowed_item_types(); | |
| 418 | + | assert!(types.contains(&ItemType::Text)); | |
| 419 | + | assert_eq!(types.len(), 1); | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | #[test] | |
| 423 | + | fn project_feature_allowed_item_types_non_content() { | |
| 424 | + | assert!(ProjectFeature::Blog.allowed_item_types().is_empty()); | |
| 425 | + | assert!( | |
| 426 | + | ProjectFeature::Subscriptions | |
| 427 | + | .allowed_item_types() | |
| 428 | + | .is_empty() | |
| 429 | + | ); | |
| 430 | + | assert!(ProjectFeature::LicenseKeys.allowed_item_types().is_empty()); | |
| 431 | + | assert!(ProjectFeature::SourceCode.allowed_item_types().is_empty()); | |
| 432 | + | assert!(ProjectFeature::CloudSync.allowed_item_types().is_empty()); | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | #[test] | |
| 436 | + | fn project_feature_allowed_cards_filtered() { | |
| 437 | + | let cards = ProjectFeature::allowed_item_type_cards(&["audio".into()]); | |
| 438 | + | let values: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect(); | |
| 439 | + | assert!(values.contains(&"audio")); | |
| 440 | + | assert!(values.contains(&"sample")); | |
| 441 | + | assert!(values.contains(&"preset")); | |
| 442 | + | assert!(values.contains(&"bundle")); // Bundle always included | |
| 443 | + | assert!(!values.contains(&"text")); | |
| 444 | + | assert!(!values.contains(&"digital")); | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | #[test] | |
| 448 | + | fn project_feature_allowed_cards_combined() { | |
| 449 | + | let cards = ProjectFeature::allowed_item_type_cards(&["audio".into(), "text".into()]); | |
| 450 | + | let values: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect(); | |
| 451 | + | assert!(values.contains(&"audio")); | |
| 452 | + | assert!(values.contains(&"text")); | |
| 453 | + | assert!(values.contains(&"bundle")); // Bundle always included | |
| 454 | + | assert!(!values.contains(&"digital")); | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | #[test] | |
| 458 | + | fn project_feature_allowed_cards_empty_features_shows_all() { | |
| 459 | + | let cards = ProjectFeature::allowed_item_type_cards(&[]); | |
| 460 | + | assert_eq!(cards.len(), 11); // 10 content types + bundle | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | #[test] | |
| 464 | + | fn project_feature_allowed_cards_non_content_features_shows_all() { | |
| 465 | + | let cards = ProjectFeature::allowed_item_type_cards(&["blog".into(), "subscriptions".into()]); | |
| 466 | + | // Blog and subscriptions don't gate item types, so all should be shown | |
| 467 | + | assert_eq!(cards.len(), 11); // 10 content types + bundle | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | #[test] | |
| 471 | + | fn project_feature_derive_type() { | |
| 472 | + | assert_eq!( | |
| 473 | + | ProjectFeature::derive_project_type(&["audio".into(), "blog".into()]), | |
| 474 | + | ProjectType::Music, | |
| 475 | + | ); | |
| 476 | + | assert_eq!( | |
| 477 | + | ProjectFeature::derive_project_type(&["text".into()]), | |
| 478 | + | ProjectType::Blog, | |
| 479 | + | ); | |
| 480 | + | assert_eq!( | |
| 481 | + | ProjectFeature::derive_project_type(&["downloads".into(), "text".into()]), | |
| 482 | + | ProjectType::Software, | |
| 483 | + | ); | |
| 484 | + | assert_eq!( | |
| 485 | + | ProjectFeature::derive_project_type(&["subscriptions".into()]), | |
| 486 | + | ProjectType::General, | |
| 487 | + | ); | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | #[test] | |
| 491 | + | fn project_type_round_trip() { | |
| 492 | + | assert_eq!(ProjectType::Blog.to_string(), "blog"); | |
| 493 | + | assert_eq!( | |
| 494 | + | "software".parse::<ProjectType>().unwrap(), | |
| 495 | + | ProjectType::Software | |
| 496 | + | ); | |
| 497 | + | assert_eq!( | |
| 498 | + | "general".parse::<ProjectType>().unwrap(), | |
| 499 | + | ProjectType::General | |
| 500 | + | ); |
Lines truncated