lib.rs (140967B)
1 #![forbid(unsafe_code)] 2 3 pub use radroots_app_types::*; 4 5 use radroots_core::RadrootsCoreMoney; 6 use radroots_events::order::RadrootsOrderEconomics; 7 use radroots_trade::order::{RadrootsOrderProjection, RadrootsOrderStatus}; 8 use radroots_trade::validation_receipt::{ 9 RadrootsValidationReceiptProofSystem, RadrootsValidationReceiptResult, 10 RadrootsValidationReceiptType, 11 }; 12 use serde::{Deserialize, Serialize}; 13 use std::{collections::BTreeSet, error::Error, fmt, str::FromStr}; 14 use url::Url; 15 16 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 17 #[serde(rename_all = "snake_case")] 18 pub enum ActiveSurface { 19 #[default] 20 Farmer, 21 Personal, 22 } 23 24 impl ActiveSurface { 25 pub const fn storage_key(self) -> &'static str { 26 match self { 27 Self::Farmer => "farmer", 28 Self::Personal => "personal", 29 } 30 } 31 } 32 33 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 34 #[serde(rename_all = "snake_case")] 35 pub enum FarmerSection { 36 #[default] 37 Today, 38 Products, 39 Orders, 40 PackDay, 41 Farm, 42 } 43 44 impl FarmerSection { 45 pub const fn storage_key(self) -> &'static str { 46 match self { 47 Self::Today => "farmer.today", 48 Self::Products => "farmer.products", 49 Self::Orders => "farmer.orders", 50 Self::PackDay => "farmer.pack_day", 51 Self::Farm => "farmer.farm", 52 } 53 } 54 } 55 56 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 57 #[serde(rename_all = "snake_case")] 58 pub enum PersonalSection { 59 #[default] 60 Browse, 61 Search, 62 Cart, 63 Orders, 64 } 65 66 impl PersonalSection { 67 pub const fn storage_key(self) -> &'static str { 68 match self { 69 Self::Browse => "personal.browse", 70 Self::Search => "personal.search", 71 Self::Cart => "personal.cart", 72 Self::Orders => "personal.orders", 73 } 74 } 75 } 76 77 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 78 #[serde(tag = "surface", content = "section", rename_all = "snake_case")] 79 pub enum ShellSection { 80 #[default] 81 Home, 82 Account, 83 Personal(PersonalSection), 84 Farmer(FarmerSection), 85 Settings(SettingsSection), 86 } 87 88 impl ShellSection { 89 pub const fn surface(self) -> Option<ActiveSurface> { 90 match self { 91 Self::Home | Self::Account | Self::Settings(_) => None, 92 Self::Personal(_) => Some(ActiveSurface::Personal), 93 Self::Farmer(_) => Some(ActiveSurface::Farmer), 94 } 95 } 96 97 pub const fn default_for_surface(surface: ActiveSurface) -> Self { 98 match surface { 99 ActiveSurface::Personal => Self::Personal(PersonalSection::Browse), 100 ActiveSurface::Farmer => Self::Farmer(FarmerSection::Today), 101 } 102 } 103 104 pub const fn storage_key(self) -> &'static str { 105 match self { 106 Self::Home => "home", 107 Self::Account => "account", 108 Self::Personal(section) => section.storage_key(), 109 Self::Farmer(section) => section.storage_key(), 110 Self::Settings(section) => section.storage_key(), 111 } 112 } 113 } 114 115 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 116 pub struct ParseShellSectionError; 117 118 impl fmt::Display for ParseShellSectionError { 119 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 120 formatter.write_str("invalid shell section key") 121 } 122 } 123 124 impl Error for ParseShellSectionError {} 125 126 impl FromStr for ShellSection { 127 type Err = ParseShellSectionError; 128 129 fn from_str(value: &str) -> Result<Self, Self::Err> { 130 match value { 131 "home" => Ok(Self::Home), 132 "account" => Ok(Self::Account), 133 "personal.browse" => Ok(Self::Personal(PersonalSection::Browse)), 134 "personal.search" => Ok(Self::Personal(PersonalSection::Search)), 135 "personal.cart" => Ok(Self::Personal(PersonalSection::Cart)), 136 "personal.orders" => Ok(Self::Personal(PersonalSection::Orders)), 137 "farmer.today" => Ok(Self::Farmer(FarmerSection::Today)), 138 "farmer.products" => Ok(Self::Farmer(FarmerSection::Products)), 139 "farmer.orders" => Ok(Self::Farmer(FarmerSection::Orders)), 140 "farmer.pack_day" => Ok(Self::Farmer(FarmerSection::PackDay)), 141 "farmer.farm" => Ok(Self::Farmer(FarmerSection::Farm)), 142 "settings.account" => Ok(Self::Settings(SettingsSection::Account)), 143 "settings.farm" => Ok(Self::Settings(SettingsSection::Farm)), 144 "settings.settings" => Ok(Self::Settings(SettingsSection::Settings)), 145 "settings.about" => Ok(Self::Settings(SettingsSection::About)), 146 _ => Err(ParseShellSectionError), 147 } 148 } 149 } 150 151 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] 152 #[serde(rename_all = "snake_case")] 153 pub enum IdentityBlockedReason { 154 RuntimeUnavailable, 155 HostVaultUnavailable, 156 } 157 158 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 159 #[serde(tag = "status", content = "reason", rename_all = "snake_case")] 160 pub enum IdentityReadiness { 161 #[default] 162 MissingAccount, 163 Ready, 164 Blocked(IdentityBlockedReason), 165 } 166 167 impl IdentityReadiness { 168 pub const fn storage_key(self) -> &'static str { 169 match self { 170 Self::MissingAccount => "missing_account", 171 Self::Ready => "ready", 172 Self::Blocked(IdentityBlockedReason::RuntimeUnavailable) => "runtime_unavailable", 173 Self::Blocked(IdentityBlockedReason::HostVaultUnavailable) => "host_vault_unavailable", 174 } 175 } 176 } 177 178 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] 179 pub struct SelectedSurfaceProjection { 180 pub active_surface: ActiveSurface, 181 } 182 183 impl Default for SelectedSurfaceProjection { 184 fn default() -> Self { 185 Self::new(ActiveSurface::Personal) 186 } 187 } 188 189 impl SelectedSurfaceProjection { 190 pub const fn new(active_surface: ActiveSurface) -> Self { 191 Self { active_surface } 192 } 193 } 194 195 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 196 pub struct FarmerActivationProjection { 197 pub farm_id: Option<FarmId>, 198 } 199 200 impl FarmerActivationProjection { 201 pub const fn inactive() -> Self { 202 Self { farm_id: None } 203 } 204 205 pub fn active(farm_id: FarmId) -> Self { 206 Self { 207 farm_id: Some(farm_id), 208 } 209 } 210 211 pub const fn is_active(&self) -> bool { 212 self.farm_id.is_some() 213 } 214 } 215 216 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 217 pub struct AccountSummary { 218 pub account_id: String, 219 pub npub: String, 220 pub label: Option<String>, 221 pub custody: AccountCustody, 222 } 223 224 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 225 pub struct AccountSurfaceActivationProjection { 226 pub account_id: String, 227 pub selected_surface: SelectedSurfaceProjection, 228 pub farmer_activation: FarmerActivationProjection, 229 } 230 231 impl AccountSurfaceActivationProjection { 232 pub fn new( 233 account_id: impl Into<String>, 234 selected_surface: SelectedSurfaceProjection, 235 farmer_activation: FarmerActivationProjection, 236 ) -> Self { 237 let active_surface = if farmer_activation.is_active() { 238 selected_surface.active_surface 239 } else { 240 ActiveSurface::Personal 241 }; 242 243 Self { 244 account_id: account_id.into(), 245 selected_surface: SelectedSurfaceProjection::new(active_surface), 246 farmer_activation, 247 } 248 } 249 250 pub const fn active_surface(&self) -> ActiveSurface { 251 self.selected_surface.active_surface 252 } 253 } 254 255 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 256 pub struct SelectedAccountProjection { 257 pub account: AccountSummary, 258 pub selected_surface: SelectedSurfaceProjection, 259 pub farmer_activation: FarmerActivationProjection, 260 } 261 262 impl SelectedAccountProjection { 263 pub fn new( 264 account: AccountSummary, 265 selected_surface: SelectedSurfaceProjection, 266 farmer_activation: FarmerActivationProjection, 267 ) -> Self { 268 let active_surface = if farmer_activation.is_active() { 269 selected_surface.active_surface 270 } else { 271 ActiveSurface::Personal 272 }; 273 274 Self { 275 account, 276 selected_surface: SelectedSurfaceProjection::new(active_surface), 277 farmer_activation, 278 } 279 } 280 281 pub fn from_surface_activation( 282 account: AccountSummary, 283 activation: AccountSurfaceActivationProjection, 284 ) -> Self { 285 Self::new( 286 account, 287 activation.selected_surface, 288 activation.farmer_activation, 289 ) 290 } 291 292 pub const fn active_surface(&self) -> ActiveSurface { 293 self.selected_surface.active_surface 294 } 295 } 296 297 impl From<&SelectedAccountProjection> for AccountSurfaceActivationProjection { 298 fn from(value: &SelectedAccountProjection) -> Self { 299 Self::new( 300 value.account.account_id.clone(), 301 value.selected_surface, 302 value.farmer_activation.clone(), 303 ) 304 } 305 } 306 307 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 308 #[serde(rename_all = "snake_case")] 309 pub enum AppStartupGate { 310 Blocked, 311 #[default] 312 SetupRequired, 313 Personal, 314 Farmer, 315 } 316 317 impl AppStartupGate { 318 pub const fn storage_key(self) -> &'static str { 319 match self { 320 Self::Blocked => "blocked", 321 Self::SetupRequired => "setup_required", 322 Self::Personal => "personal", 323 Self::Farmer => "farmer", 324 } 325 } 326 } 327 328 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 329 #[serde(rename_all = "snake_case")] 330 pub enum LoggedOutStartupPhase { 331 #[default] 332 ContinuePrompt, 333 IdentityChoice, 334 GenerateKeyStarting, 335 SignerEntry, 336 } 337 338 impl LoggedOutStartupPhase { 339 pub const fn storage_key(self) -> &'static str { 340 match self { 341 Self::ContinuePrompt => "continue_prompt", 342 Self::IdentityChoice => "identity_choice", 343 Self::GenerateKeyStarting => "generate_key_starting", 344 Self::SignerEntry => "signer_entry", 345 } 346 } 347 } 348 349 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] 350 #[serde(rename_all = "snake_case")] 351 pub enum StartupSignerSourceKind { 352 BunkerUri, 353 DiscoveryUrl, 354 } 355 356 impl StartupSignerSourceKind { 357 pub const fn storage_key(self) -> &'static str { 358 match self { 359 Self::BunkerUri => "bunker_uri", 360 Self::DiscoveryUrl => "discovery_url", 361 } 362 } 363 } 364 365 #[derive(Clone, Debug, Eq, PartialEq)] 366 pub enum ParseStartupSignerSourceError { 367 EmptyInput, 368 UnsupportedClientUri, 369 UnsupportedSource, 370 MissingDiscoveryUri, 371 } 372 373 impl fmt::Display for ParseStartupSignerSourceError { 374 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 375 match self { 376 Self::EmptyInput => formatter.write_str("signer source input must not be empty"), 377 Self::UnsupportedClientUri => formatter.write_str( 378 "client nostrconnect URIs are not accepted by the app signer entry flow", 379 ), 380 Self::UnsupportedSource => { 381 formatter.write_str("signer source input must be a bunker URI or discovery URL") 382 } 383 Self::MissingDiscoveryUri => { 384 formatter.write_str("discovery URL must include a non-empty uri query parameter") 385 } 386 } 387 } 388 } 389 390 impl Error for ParseStartupSignerSourceError {} 391 392 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 393 #[serde(tag = "kind", content = "value", rename_all = "snake_case")] 394 pub enum StartupSignerSource { 395 BunkerUri(String), 396 DiscoveryUrl(String), 397 } 398 399 impl StartupSignerSource { 400 pub const fn kind(&self) -> StartupSignerSourceKind { 401 match self { 402 Self::BunkerUri(_) => StartupSignerSourceKind::BunkerUri, 403 Self::DiscoveryUrl(_) => StartupSignerSourceKind::DiscoveryUrl, 404 } 405 } 406 407 pub fn value(&self) -> &str { 408 match self { 409 Self::BunkerUri(value) | Self::DiscoveryUrl(value) => value, 410 } 411 } 412 } 413 414 impl FromStr for StartupSignerSource { 415 type Err = ParseStartupSignerSourceError; 416 417 fn from_str(value: &str) -> Result<Self, Self::Err> { 418 let trimmed = value.trim(); 419 if trimmed.is_empty() { 420 return Err(ParseStartupSignerSourceError::EmptyInput); 421 } 422 423 if trimmed.starts_with("nostrconnect://") { 424 return Err(ParseStartupSignerSourceError::UnsupportedClientUri); 425 } 426 427 if trimmed.starts_with("bunker://") { 428 return Ok(Self::BunkerUri(trimmed.to_owned())); 429 } 430 431 let url = 432 Url::parse(trimmed).map_err(|_| ParseStartupSignerSourceError::UnsupportedSource)?; 433 let has_discovery_uri = url 434 .query_pairs() 435 .any(|(key, value)| key == "uri" && !value.trim().is_empty()); 436 437 if !has_discovery_uri { 438 return Err(ParseStartupSignerSourceError::MissingDiscoveryUri); 439 } 440 441 Ok(Self::DiscoveryUrl(trimmed.to_owned())) 442 } 443 } 444 445 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 446 pub struct StartupSignerEntryProjection { 447 pub source_input: String, 448 } 449 450 impl StartupSignerEntryProjection { 451 pub fn new(source_input: impl Into<String>) -> Self { 452 Self { 453 source_input: source_input.into(), 454 } 455 } 456 457 pub fn parsed_source(&self) -> Result<StartupSignerSource, ParseStartupSignerSourceError> { 458 self.source_input.parse() 459 } 460 461 pub fn set_source_input(&mut self, source_input: impl Into<String>) { 462 self.source_input = source_input.into(); 463 } 464 } 465 466 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 467 pub struct LoggedOutStartupProjection { 468 pub phase: LoggedOutStartupPhase, 469 pub signer_entry: StartupSignerEntryProjection, 470 } 471 472 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 473 #[serde(tag = "kind", content = "account_id", rename_all = "snake_case")] 474 pub enum BuyerContext { 475 #[default] 476 Guest, 477 Account(String), 478 } 479 480 impl BuyerContext { 481 pub const fn guest() -> Self { 482 Self::Guest 483 } 484 485 pub fn account(account_id: impl Into<String>) -> Self { 486 Self::Account(account_id.into()) 487 } 488 489 pub fn storage_key(&self) -> String { 490 match self { 491 Self::Guest => "guest".to_owned(), 492 Self::Account(account_id) => format!("account:{account_id}"), 493 } 494 } 495 } 496 497 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 498 #[serde(rename_all = "snake_case")] 499 pub enum PersonalEntryState { 500 Blocked, 501 #[default] 502 Guest, 503 SignedIn, 504 } 505 506 impl PersonalEntryState { 507 pub const fn storage_key(self) -> &'static str { 508 match self { 509 Self::Blocked => "blocked", 510 Self::Guest => "guest", 511 Self::SignedIn => "signed_in", 512 } 513 } 514 } 515 516 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 517 pub struct PersonalEntryProjection { 518 pub state: PersonalEntryState, 519 pub selected_account: Option<SelectedAccountProjection>, 520 pub can_enter_farmer_workspace: bool, 521 } 522 523 impl PersonalEntryProjection { 524 pub fn blocked(selected_account: Option<SelectedAccountProjection>) -> Self { 525 let can_enter_farmer_workspace = selected_account 526 .as_ref() 527 .is_some_and(|account| account.farmer_activation.is_active()); 528 529 Self { 530 state: PersonalEntryState::Blocked, 531 selected_account, 532 can_enter_farmer_workspace, 533 } 534 } 535 536 pub const fn guest() -> Self { 537 Self { 538 state: PersonalEntryState::Guest, 539 selected_account: None, 540 can_enter_farmer_workspace: false, 541 } 542 } 543 544 pub fn signed_in(selected_account: SelectedAccountProjection) -> Self { 545 let can_enter_farmer_workspace = selected_account.farmer_activation.is_active(); 546 547 Self { 548 state: PersonalEntryState::SignedIn, 549 selected_account: Some(selected_account), 550 can_enter_farmer_workspace, 551 } 552 } 553 } 554 555 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 556 pub struct AppIdentityProjection { 557 pub readiness: IdentityReadiness, 558 pub roster: Vec<AccountSummary>, 559 pub selected_account: Option<SelectedAccountProjection>, 560 } 561 562 impl AppIdentityProjection { 563 pub fn missing() -> Self { 564 Self::with_readiness(IdentityReadiness::MissingAccount, Vec::new(), None) 565 } 566 567 pub fn missing_with_roster(roster: Vec<AccountSummary>) -> Self { 568 Self::with_readiness(IdentityReadiness::MissingAccount, roster, None) 569 } 570 571 pub fn blocked(reason: IdentityBlockedReason) -> Self { 572 Self::with_readiness(IdentityReadiness::Blocked(reason), Vec::new(), None) 573 } 574 575 pub fn blocked_with_selection( 576 reason: IdentityBlockedReason, 577 roster: Vec<AccountSummary>, 578 selected_account: Option<SelectedAccountProjection>, 579 ) -> Self { 580 Self::with_readiness(IdentityReadiness::Blocked(reason), roster, selected_account) 581 } 582 583 pub fn ready(roster: Vec<AccountSummary>, selected_account: SelectedAccountProjection) -> Self { 584 Self::with_readiness(IdentityReadiness::Ready, roster, Some(selected_account)) 585 } 586 587 pub fn with_readiness( 588 readiness: IdentityReadiness, 589 mut roster: Vec<AccountSummary>, 590 selected_account: Option<SelectedAccountProjection>, 591 ) -> Self { 592 if let Some(selected_account) = selected_account.as_ref() 593 && !roster 594 .iter() 595 .any(|account| account.account_id == selected_account.account.account_id) 596 { 597 roster.insert(0, selected_account.account.clone()); 598 } 599 600 Self { 601 readiness, 602 roster, 603 selected_account, 604 } 605 } 606 607 pub fn startup_gate(&self) -> AppStartupGate { 608 match self.readiness { 609 IdentityReadiness::MissingAccount => AppStartupGate::SetupRequired, 610 IdentityReadiness::Blocked(_) => AppStartupGate::Blocked, 611 IdentityReadiness::Ready => self 612 .selected_account 613 .as_ref() 614 .map(|account| { 615 if account.farmer_activation.is_active() 616 && account.active_surface() == ActiveSurface::Farmer 617 { 618 AppStartupGate::Farmer 619 } else { 620 AppStartupGate::Personal 621 } 622 }) 623 .unwrap_or(AppStartupGate::SetupRequired), 624 } 625 } 626 627 pub fn settings_account(&self) -> SettingsAccountProjection { 628 self.into() 629 } 630 631 pub fn personal_entry(&self) -> PersonalEntryProjection { 632 match self.readiness { 633 IdentityReadiness::MissingAccount => PersonalEntryProjection::guest(), 634 IdentityReadiness::Blocked(_) => { 635 PersonalEntryProjection::blocked(self.selected_account.clone()) 636 } 637 IdentityReadiness::Ready => self 638 .selected_account 639 .clone() 640 .map(PersonalEntryProjection::signed_in) 641 .unwrap_or_else(PersonalEntryProjection::guest), 642 } 643 } 644 645 pub fn buyer_context(&self) -> BuyerContext { 646 self.selected_account 647 .as_ref() 648 .map(|account| BuyerContext::account(account.account.account_id.clone())) 649 .unwrap_or_default() 650 } 651 } 652 653 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 654 pub struct SettingsAccountProjection { 655 pub readiness: IdentityReadiness, 656 pub roster: Vec<AccountSummary>, 657 pub selected_account: Option<SelectedAccountProjection>, 658 } 659 660 impl From<&AppIdentityProjection> for SettingsAccountProjection { 661 fn from(value: &AppIdentityProjection) -> Self { 662 Self { 663 readiness: value.readiness, 664 roster: value.roster.clone(), 665 selected_account: value.selected_account.clone(), 666 } 667 } 668 } 669 670 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 671 pub struct FarmRulesProjection { 672 pub farm_profile: Option<FarmProfileRecord>, 673 pub pickup_locations: Vec<PickupLocationRecord>, 674 pub operating_rules: Option<FarmOperatingRulesRecord>, 675 pub fulfillment_windows: Vec<FulfillmentWindowRecord>, 676 pub blackout_periods: Vec<BlackoutPeriodRecord>, 677 pub readiness: FarmRulesReadiness, 678 } 679 680 impl Default for FarmRulesProjection { 681 fn default() -> Self { 682 Self { 683 farm_profile: None, 684 pickup_locations: Vec::new(), 685 operating_rules: None, 686 fulfillment_windows: Vec::new(), 687 blackout_periods: Vec::new(), 688 readiness: FarmRulesReadiness::missing_v1_basics(), 689 } 690 } 691 } 692 693 impl FarmRulesProjection { 694 pub fn is_ready(&self) -> bool { 695 self.readiness.is_ready() 696 } 697 } 698 699 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 700 #[serde(rename_all = "snake_case")] 701 pub enum ProductsFilter { 702 #[default] 703 All, 704 Live, 705 Drafts, 706 NeedAttention, 707 Paused, 708 Archived, 709 } 710 711 impl ProductsFilter { 712 pub const fn storage_key(self) -> &'static str { 713 match self { 714 Self::All => "all", 715 Self::Live => "live", 716 Self::Drafts => "drafts", 717 Self::NeedAttention => "need_attention", 718 Self::Paused => "paused", 719 Self::Archived => "archived", 720 } 721 } 722 } 723 724 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 725 #[serde(rename_all = "snake_case")] 726 pub enum ProductsSort { 727 #[default] 728 Updated, 729 Name, 730 Availability, 731 Stock, 732 Price, 733 } 734 735 impl ProductsSort { 736 pub const fn storage_key(self) -> &'static str { 737 match self { 738 Self::Updated => "updated", 739 Self::Name => "name", 740 Self::Availability => "availability", 741 Self::Stock => "stock", 742 Self::Price => "price", 743 } 744 } 745 } 746 747 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 748 #[serde(rename_all = "snake_case")] 749 pub enum ProductAttentionState { 750 #[default] 751 Healthy, 752 LowStock, 753 SoldOut, 754 MissingAvailability, 755 NoFutureAvailability, 756 MissingDetails, 757 } 758 759 impl ProductAttentionState { 760 pub const fn storage_key(self) -> &'static str { 761 match self { 762 Self::Healthy => "healthy", 763 Self::LowStock => "low_stock", 764 Self::SoldOut => "sold_out", 765 Self::MissingAvailability => "missing_availability", 766 Self::NoFutureAvailability => "no_future_availability", 767 Self::MissingDetails => "missing_details", 768 } 769 } 770 771 pub const fn requires_attention(self) -> bool { 772 !matches!(self, Self::Healthy) 773 } 774 } 775 776 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] 777 #[serde(rename_all = "snake_case")] 778 pub enum ProductAvailabilityState { 779 Scheduled, 780 Open, 781 MissingWindow, 782 NoFutureWindow, 783 } 784 785 impl ProductAvailabilityState { 786 pub const fn storage_key(self) -> &'static str { 787 match self { 788 Self::Scheduled => "scheduled", 789 Self::Open => "open", 790 Self::MissingWindow => "missing_window", 791 Self::NoFutureWindow => "no_future_window", 792 } 793 } 794 } 795 796 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 797 pub struct ProductAvailabilitySummary { 798 pub state: ProductAvailabilityState, 799 pub label: String, 800 } 801 802 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] 803 #[serde(rename_all = "snake_case")] 804 pub enum ProductStockState { 805 Unset, 806 InStock, 807 LowStock, 808 SoldOut, 809 } 810 811 impl ProductStockState { 812 pub const fn storage_key(self) -> &'static str { 813 match self { 814 Self::Unset => "unset", 815 Self::InStock => "in_stock", 816 Self::LowStock => "low_stock", 817 Self::SoldOut => "sold_out", 818 } 819 } 820 821 pub const fn requires_attention(self) -> bool { 822 matches!(self, Self::LowStock | Self::SoldOut) 823 } 824 } 825 826 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 827 pub struct ProductStockSummary { 828 pub quantity: Option<u32>, 829 pub unit_label: Option<String>, 830 pub state: ProductStockState, 831 } 832 833 impl ProductStockSummary { 834 pub const fn requires_attention(&self) -> bool { 835 self.state.requires_attention() 836 } 837 } 838 839 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 840 pub struct ProductPricePresentation { 841 pub amount_minor_units: u32, 842 pub currency_code: String, 843 pub unit_label: String, 844 } 845 846 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 847 pub struct ProductsListSummary { 848 pub total_products: u32, 849 pub live_products: u32, 850 pub draft_products: u32, 851 pub need_attention_products: u32, 852 } 853 854 impl ProductsListSummary { 855 pub const fn has_products(&self) -> bool { 856 self.total_products > 0 857 } 858 } 859 860 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 861 pub struct ProductsListRow { 862 pub product_id: ProductId, 863 pub farm_id: FarmId, 864 pub title: String, 865 pub subtitle: Option<String>, 866 pub status: ProductStatus, 867 pub attention_state: ProductAttentionState, 868 pub availability: ProductAvailabilitySummary, 869 pub stock: ProductStockSummary, 870 pub price: Option<ProductPricePresentation>, 871 pub updated_at: String, 872 } 873 874 impl ProductsListRow { 875 pub const fn requires_attention(&self) -> bool { 876 self.attention_state.requires_attention() || self.stock.requires_attention() 877 } 878 } 879 880 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 881 pub struct ProductsListProjection { 882 pub summary: ProductsListSummary, 883 pub rows: Vec<ProductsListRow>, 884 } 885 886 impl ProductsListProjection { 887 pub fn is_empty(&self) -> bool { 888 self.rows.is_empty() 889 } 890 } 891 892 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 893 pub struct ProductEditorDraft { 894 pub title: String, 895 pub subtitle: String, 896 pub category: String, 897 pub unit_label: String, 898 pub price_minor_units: Option<u32>, 899 pub price_currency: String, 900 pub stock_quantity: Option<u32>, 901 pub availability_window_id: Option<FulfillmentWindowId>, 902 pub status: ProductStatus, 903 } 904 905 impl Default for ProductEditorDraft { 906 fn default() -> Self { 907 Self { 908 title: String::new(), 909 subtitle: String::new(), 910 category: String::new(), 911 unit_label: String::new(), 912 price_minor_units: None, 913 price_currency: "USD".to_owned(), 914 stock_quantity: None, 915 availability_window_id: None, 916 status: ProductStatus::Draft, 917 } 918 } 919 } 920 921 impl ProductEditorDraft { 922 pub fn publish_blockers(&self) -> Vec<ProductPublishBlocker> { 923 let mut blockers = Vec::new(); 924 925 if self.title.trim().is_empty() { 926 blockers.push(ProductPublishBlocker::AddProductName); 927 } 928 929 if self.category.trim().is_empty() { 930 blockers.push(ProductPublishBlocker::ChooseCategory); 931 } 932 933 if self.unit_label.trim().is_empty() { 934 blockers.push(ProductPublishBlocker::ChooseUnit); 935 } 936 937 if self.price_minor_units.is_none_or(|value| value == 0) { 938 blockers.push(ProductPublishBlocker::SetPrice); 939 } 940 941 if self.stock_quantity.is_none() { 942 blockers.push(ProductPublishBlocker::SetStock); 943 } 944 945 if self.availability_window_id.is_none() { 946 blockers.push(ProductPublishBlocker::AttachAvailability); 947 } 948 949 blockers 950 } 951 952 pub fn is_publish_ready(&self) -> bool { 953 self.publish_blockers().is_empty() 954 } 955 } 956 957 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 958 pub struct BuyerListingRow { 959 pub product_id: ProductId, 960 pub farm_id: FarmId, 961 pub farm_display_name: String, 962 pub listing_relays: Vec<String>, 963 pub title: String, 964 pub subtitle: Option<String>, 965 pub price: ProductPricePresentation, 966 pub availability: ProductAvailabilitySummary, 967 pub stock: ProductStockSummary, 968 pub fulfillment_methods: BTreeSet<FarmOrderMethod>, 969 pub next_fulfillment_window_label: Option<String>, 970 } 971 972 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 973 pub struct BuyerListingsProjection { 974 pub rows: Vec<BuyerListingRow>, 975 } 976 977 impl BuyerListingsProjection { 978 pub fn is_empty(&self) -> bool { 979 self.rows.is_empty() 980 } 981 } 982 983 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 984 pub struct BuyerProductDetailProjection { 985 pub listing: BuyerListingRow, 986 pub detail_text: Option<String>, 987 pub selected_quantity: u32, 988 } 989 990 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 991 pub struct BuyerCartLineProjection { 992 pub product_id: ProductId, 993 pub farm_id: FarmId, 994 pub farm_display_name: String, 995 pub title: String, 996 pub quantity: u32, 997 pub unit_price: ProductPricePresentation, 998 pub line_total_minor_units: u32, 999 pub fulfillment_summary: String, 1000 } 1001 1002 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 1003 pub struct BuyerCartReplaceConfirmationProjection { 1004 pub current_farm_display_name: String, 1005 pub incoming_farm_display_name: String, 1006 } 1007 1008 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 1009 pub struct BuyerCartProjection { 1010 pub farm_id: Option<FarmId>, 1011 pub farm_display_name: Option<String>, 1012 pub lines: Vec<BuyerCartLineProjection>, 1013 pub subtotal_minor_units: Option<u32>, 1014 pub currency_code: Option<String>, 1015 pub replace_confirmation: Option<BuyerCartReplaceConfirmationProjection>, 1016 } 1017 1018 impl BuyerCartProjection { 1019 pub fn is_empty(&self) -> bool { 1020 self.lines.is_empty() 1021 } 1022 } 1023 1024 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 1025 pub struct BuyerOrderReviewDraft { 1026 pub name: String, 1027 pub email: String, 1028 pub phone: String, 1029 pub order_note: String, 1030 } 1031 1032 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 1033 pub struct BuyerOrderReviewSummaryProjection { 1034 pub farm_display_name: Option<String>, 1035 pub fulfillment_summary: Option<String>, 1036 pub line_count: u32, 1037 pub subtotal_minor_units: Option<u32>, 1038 pub currency_code: Option<String>, 1039 } 1040 1041 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] 1042 #[serde(rename_all = "snake_case")] 1043 pub enum BuyerOrderReviewDisabledReason { 1044 EmptyCart, 1045 MissingFulfillment, 1046 MissingName, 1047 MissingEmail, 1048 AccountRequired, 1049 } 1050 1051 impl BuyerOrderReviewDisabledReason { 1052 pub const fn storage_key(self) -> &'static str { 1053 match self { 1054 Self::EmptyCart => "empty_cart", 1055 Self::MissingFulfillment => "missing_fulfillment", 1056 Self::MissingName => "missing_name", 1057 Self::MissingEmail => "missing_email", 1058 Self::AccountRequired => "account_required", 1059 } 1060 } 1061 } 1062 1063 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 1064 pub struct BuyerOrderReviewProjection { 1065 pub draft: BuyerOrderReviewDraft, 1066 pub summary: BuyerOrderReviewSummaryProjection, 1067 pub can_place_order: bool, 1068 pub place_order_disabled_reason: Option<BuyerOrderReviewDisabledReason>, 1069 } 1070 1071 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 1072 #[serde(rename_all = "snake_case")] 1073 pub enum TradeAgreementStatus { 1074 #[default] 1075 Ordered, 1076 Confirmed, 1077 Declined, 1078 Cancelled, 1079 NeedsReview, 1080 } 1081 1082 impl TradeAgreementStatus { 1083 pub const fn storage_key(self) -> &'static str { 1084 match self { 1085 Self::Ordered => "ordered", 1086 Self::Confirmed => "confirmed", 1087 Self::Declined => "declined", 1088 Self::Cancelled => "cancelled", 1089 Self::NeedsReview => "needs_review", 1090 } 1091 } 1092 1093 pub const fn label_key_id(self) -> &'static str { 1094 match self { 1095 Self::Ordered => "messages.trade.workflow.agreement.ordered", 1096 Self::Confirmed => "messages.trade.workflow.agreement.confirmed", 1097 Self::Declined => "messages.trade.workflow.agreement.declined", 1098 Self::Cancelled => "messages.trade.workflow.agreement.cancelled", 1099 Self::NeedsReview => "messages.trade.workflow.agreement.needs_review", 1100 } 1101 } 1102 1103 pub const fn from_active_order_status(status: &RadrootsOrderStatus) -> Self { 1104 match status { 1105 RadrootsOrderStatus::Missing => Self::NeedsReview, 1106 RadrootsOrderStatus::Requested => Self::Ordered, 1107 RadrootsOrderStatus::Accepted => Self::Confirmed, 1108 RadrootsOrderStatus::Declined => Self::Declined, 1109 RadrootsOrderStatus::Cancelled => Self::Cancelled, 1110 RadrootsOrderStatus::Invalid => Self::NeedsReview, 1111 } 1112 } 1113 } 1114 1115 impl From<&RadrootsOrderStatus> for TradeAgreementStatus { 1116 fn from(status: &RadrootsOrderStatus) -> Self { 1117 Self::from_active_order_status(status) 1118 } 1119 } 1120 1121 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 1122 #[serde(rename_all = "snake_case")] 1123 pub enum TradeRevisionStatus { 1124 #[default] 1125 None, 1126 ChangeProposed, 1127 Updated, 1128 KeptAsPlaced, 1129 } 1130 1131 #[derive(Clone, Debug, Eq, PartialEq)] 1132 pub struct ParseTradeRevisionStatusError { 1133 value: String, 1134 } 1135 1136 impl ParseTradeRevisionStatusError { 1137 pub fn value(&self) -> &str { 1138 self.value.as_str() 1139 } 1140 } 1141 1142 impl fmt::Display for ParseTradeRevisionStatusError { 1143 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 1144 write!(formatter, "invalid trade revision status `{}`", self.value) 1145 } 1146 } 1147 1148 impl Error for ParseTradeRevisionStatusError {} 1149 1150 impl TradeRevisionStatus { 1151 pub const fn storage_key(self) -> &'static str { 1152 match self { 1153 Self::None => "none", 1154 Self::ChangeProposed => "change_proposed", 1155 Self::Updated => "updated", 1156 Self::KeptAsPlaced => "kept_as_placed", 1157 } 1158 } 1159 1160 pub const fn label_key_id(self) -> &'static str { 1161 match self { 1162 Self::None => "messages.trade.workflow.revision.none", 1163 Self::ChangeProposed => "messages.trade.workflow.revision.change_proposed", 1164 Self::Updated => "messages.trade.workflow.revision.updated", 1165 Self::KeptAsPlaced => "messages.trade.workflow.revision.kept_as_placed", 1166 } 1167 } 1168 1169 pub fn try_from_storage_key(value: &str) -> Result<Self, ParseTradeRevisionStatusError> { 1170 match value { 1171 "none" => Ok(Self::None), 1172 "change_proposed" => Ok(Self::ChangeProposed), 1173 "updated" => Ok(Self::Updated), 1174 "kept_as_placed" => Ok(Self::KeptAsPlaced), 1175 _ => Err(ParseTradeRevisionStatusError { 1176 value: value.to_owned(), 1177 }), 1178 } 1179 } 1180 } 1181 1182 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 1183 #[serde(rename_all = "snake_case")] 1184 pub enum TradeInventoryStatus { 1185 Available, 1186 Reserved, 1187 SoldOut, 1188 #[default] 1189 NeedsReview, 1190 } 1191 1192 impl TradeInventoryStatus { 1193 pub const fn storage_key(self) -> &'static str { 1194 match self { 1195 Self::Available => "available", 1196 Self::Reserved => "reserved", 1197 Self::SoldOut => "sold_out", 1198 Self::NeedsReview => "needs_review", 1199 } 1200 } 1201 1202 pub const fn label_key_id(self) -> &'static str { 1203 match self { 1204 Self::Available => "messages.trade.workflow.inventory.available", 1205 Self::Reserved => "messages.trade.workflow.inventory.reserved", 1206 Self::SoldOut => "messages.trade.workflow.inventory.sold_out", 1207 Self::NeedsReview => "messages.trade.workflow.inventory.needs_review", 1208 } 1209 } 1210 1211 pub fn from_active_order_projection(projection: &RadrootsOrderProjection) -> Self { 1212 match projection.status { 1213 RadrootsOrderStatus::Requested => Self::NeedsReview, 1214 RadrootsOrderStatus::Accepted => Self::Reserved, 1215 RadrootsOrderStatus::Declined | RadrootsOrderStatus::Cancelled => Self::Available, 1216 RadrootsOrderStatus::Missing | RadrootsOrderStatus::Invalid => Self::NeedsReview, 1217 } 1218 } 1219 } 1220 1221 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)] 1222 #[serde(rename_all = "snake_case")] 1223 pub enum TradeWorkflowSource { 1224 App, 1225 Cli, 1226 Relay, 1227 LocalEvents, 1228 #[default] 1229 Unknown, 1230 } 1231 1232 impl TradeWorkflowSource { 1233 pub const fn storage_key(self) -> &'static str { 1234 match self { 1235 Self::App => "app", 1236 Self::Cli => "cli", 1237 Self::Relay => "relay", 1238 Self::LocalEvents => "local_events", 1239 Self::Unknown => "unknown", 1240 } 1241 } 1242 1243 pub const fn label_key_id(self) -> &'static str { 1244 match self { 1245 Self::App => "messages.trade.workflow.provenance.app", 1246 Self::Cli => "messages.trade.workflow.provenance.cli", 1247 Self::Relay => "messages.trade.workflow.provenance.relay", 1248 Self::LocalEvents => "messages.trade.workflow.provenance.local_events", 1249 Self::Unknown => "messages.trade.workflow.provenance.unknown", 1250 } 1251 } 1252 } 1253 1254 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 1255 pub struct TradeEconomicsProjection { 1256 pub subtotal_minor_units: Option<u32>, 1257 pub discount_total_minor_units: Option<u32>, 1258 pub adjustment_total_minor_units: Option<u32>, 1259 pub total_minor_units: Option<u32>, 1260 pub currency_code: Option<String>, 1261 } 1262 1263 impl TradeEconomicsProjection { 1264 pub fn from_trade_order_economics(economics: &RadrootsOrderEconomics) -> Self { 1265 Self { 1266 subtotal_minor_units: money_minor_units(&economics.subtotal), 1267 discount_total_minor_units: money_minor_units(&economics.discount_total), 1268 adjustment_total_minor_units: money_minor_units(&economics.adjustment_total), 1269 total_minor_units: money_minor_units(&economics.total), 1270 currency_code: Some(economics.currency.to_string()), 1271 } 1272 } 1273 } 1274 1275 impl From<&RadrootsOrderEconomics> for TradeEconomicsProjection { 1276 fn from(economics: &RadrootsOrderEconomics) -> Self { 1277 Self::from_trade_order_economics(economics) 1278 } 1279 } 1280 1281 fn money_minor_units(money: &RadrootsCoreMoney) -> Option<u32> { 1282 money.to_minor_units_u32_exact().ok() 1283 } 1284 1285 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 1286 pub struct TradeProvenanceProjection { 1287 pub primary_source: TradeWorkflowSource, 1288 pub sources: BTreeSet<TradeWorkflowSource>, 1289 pub last_event_id: Option<String>, 1290 } 1291 1292 impl TradeProvenanceProjection { 1293 pub fn new( 1294 primary_source: TradeWorkflowSource, 1295 sources: impl IntoIterator<Item = TradeWorkflowSource>, 1296 ) -> Self { 1297 let mut sources = sources.into_iter().collect::<BTreeSet<_>>(); 1298 sources.insert(primary_source); 1299 Self { 1300 primary_source, 1301 sources, 1302 last_event_id: None, 1303 } 1304 } 1305 1306 pub fn from_primary_source(primary_source: TradeWorkflowSource) -> Self { 1307 Self::new(primary_source, [primary_source]) 1308 } 1309 1310 pub fn with_last_event_id(mut self, last_event_id: Option<String>) -> Self { 1311 self.last_event_id = last_event_id; 1312 self 1313 } 1314 } 1315 1316 impl Default for TradeProvenanceProjection { 1317 fn default() -> Self { 1318 Self::from_primary_source(TradeWorkflowSource::Unknown) 1319 } 1320 } 1321 1322 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 1323 #[serde(rename_all = "snake_case")] 1324 pub enum TradeValidationReceiptResult { 1325 #[default] 1326 Valid, 1327 NeedsReview, 1328 } 1329 1330 impl TradeValidationReceiptResult { 1331 pub const fn storage_key(self) -> &'static str { 1332 match self { 1333 Self::Valid => "valid", 1334 Self::NeedsReview => "needs_review", 1335 } 1336 } 1337 1338 pub const fn label_key_id(self) -> &'static str { 1339 match self { 1340 Self::Valid => "messages.trade.validation.result.valid", 1341 Self::NeedsReview => "messages.trade.validation.result.needs_review", 1342 } 1343 } 1344 1345 pub const fn from_validation_receipt_result(result: RadrootsValidationReceiptResult) -> Self { 1346 match result { 1347 RadrootsValidationReceiptResult::Valid => Self::Valid, 1348 RadrootsValidationReceiptResult::Invalid => Self::NeedsReview, 1349 } 1350 } 1351 } 1352 1353 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 1354 #[serde(rename_all = "snake_case")] 1355 pub enum TradeValidationReceiptType { 1356 ListingValidation, 1357 #[default] 1358 TradeTransition, 1359 InventoryState, 1360 StateCheckpoint, 1361 } 1362 1363 impl TradeValidationReceiptType { 1364 pub const fn storage_key(self) -> &'static str { 1365 match self { 1366 Self::ListingValidation => "listing_validation", 1367 Self::TradeTransition => "trade_transition", 1368 Self::InventoryState => "inventory_state", 1369 Self::StateCheckpoint => "state_checkpoint", 1370 } 1371 } 1372 1373 pub const fn label_key_id(self) -> &'static str { 1374 match self { 1375 Self::ListingValidation => "messages.trade.validation.type.listing_validation", 1376 Self::TradeTransition => "messages.trade.validation.type.trade_transition", 1377 Self::InventoryState => "messages.trade.validation.type.inventory_state", 1378 Self::StateCheckpoint => "messages.trade.validation.type.state_checkpoint", 1379 } 1380 } 1381 1382 pub const fn from_validation_receipt_type(receipt_type: RadrootsValidationReceiptType) -> Self { 1383 match receipt_type { 1384 RadrootsValidationReceiptType::ListingValidation => Self::ListingValidation, 1385 RadrootsValidationReceiptType::TradeTransition => Self::TradeTransition, 1386 RadrootsValidationReceiptType::InventoryState => Self::InventoryState, 1387 RadrootsValidationReceiptType::StateCheckpoint => Self::StateCheckpoint, 1388 } 1389 } 1390 } 1391 1392 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 1393 #[serde(rename_all = "snake_case")] 1394 pub enum TradeValidationReceiptProofSystem { 1395 #[default] 1396 None, 1397 Sp1Core, 1398 Sp1Compressed, 1399 Sp1Groth16, 1400 Sp1Plonk, 1401 } 1402 1403 impl TradeValidationReceiptProofSystem { 1404 pub const fn storage_key(self) -> &'static str { 1405 match self { 1406 Self::None => "none", 1407 Self::Sp1Core => "sp1_core", 1408 Self::Sp1Compressed => "sp1_compressed", 1409 Self::Sp1Groth16 => "sp1_groth16", 1410 Self::Sp1Plonk => "sp1_plonk", 1411 } 1412 } 1413 1414 pub const fn label_key_id(self) -> &'static str { 1415 match self { 1416 Self::None => "messages.trade.validation.proof.none", 1417 Self::Sp1Core => "messages.trade.validation.proof.sp1_core", 1418 Self::Sp1Compressed => "messages.trade.validation.proof.sp1_compressed", 1419 Self::Sp1Groth16 => "messages.trade.validation.proof.sp1_groth16", 1420 Self::Sp1Plonk => "messages.trade.validation.proof.sp1_plonk", 1421 } 1422 } 1423 1424 pub const fn from_validation_receipt_proof_system( 1425 proof_system: RadrootsValidationReceiptProofSystem, 1426 ) -> Self { 1427 match proof_system { 1428 RadrootsValidationReceiptProofSystem::None => Self::None, 1429 RadrootsValidationReceiptProofSystem::Sp1Core => Self::Sp1Core, 1430 RadrootsValidationReceiptProofSystem::Sp1Compressed => Self::Sp1Compressed, 1431 RadrootsValidationReceiptProofSystem::Sp1Groth16 => Self::Sp1Groth16, 1432 RadrootsValidationReceiptProofSystem::Sp1Plonk => Self::Sp1Plonk, 1433 } 1434 } 1435 } 1436 1437 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 1438 pub struct TradeValidationReceiptProjection { 1439 pub event_id: String, 1440 pub result: TradeValidationReceiptResult, 1441 pub receipt_type: TradeValidationReceiptType, 1442 pub proof_system: TradeValidationReceiptProofSystem, 1443 pub event_set_root: String, 1444 pub reducer_output_root: String, 1445 pub public_values_hash: String, 1446 pub target_event_id: String, 1447 pub recorded_at: u64, 1448 } 1449 1450 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 1451 pub struct TradeWorkflowProjection { 1452 pub order_id: OrderId, 1453 pub agreement: TradeAgreementStatus, 1454 pub revision: TradeRevisionStatus, 1455 pub economics: TradeEconomicsProjection, 1456 pub inventory: TradeInventoryStatus, 1457 pub provenance: TradeProvenanceProjection, 1458 } 1459 1460 impl TradeWorkflowProjection { 1461 pub fn new(order_id: OrderId, agreement: TradeAgreementStatus) -> Self { 1462 Self { 1463 order_id, 1464 agreement, 1465 revision: TradeRevisionStatus::None, 1466 economics: TradeEconomicsProjection::default(), 1467 inventory: TradeInventoryStatus::NeedsReview, 1468 provenance: TradeProvenanceProjection::default(), 1469 } 1470 } 1471 1472 pub fn from_active_order_projection( 1473 order_id: OrderId, 1474 projection: &RadrootsOrderProjection, 1475 revision: TradeRevisionStatus, 1476 provenance: TradeProvenanceProjection, 1477 ) -> Self { 1478 let mut workflow = Self::new( 1479 order_id, 1480 TradeAgreementStatus::from_active_order_status(&projection.status), 1481 ); 1482 workflow.revision = revision; 1483 workflow.economics = projection 1484 .economics 1485 .as_ref() 1486 .map(TradeEconomicsProjection::from_trade_order_economics) 1487 .unwrap_or_default(); 1488 workflow.inventory = TradeInventoryStatus::from_active_order_projection(projection); 1489 workflow.provenance = provenance 1490 .with_last_event_id(projection.last_event_id.as_ref().map(ToString::to_string)); 1491 workflow 1492 } 1493 1494 pub fn from_order_status(order_id: OrderId, status: OrderStatus) -> Self { 1495 let mut projection = match status { 1496 OrderStatus::NeedsAction => Self::new(order_id, TradeAgreementStatus::Ordered), 1497 OrderStatus::Scheduled => Self::new(order_id, TradeAgreementStatus::Confirmed), 1498 OrderStatus::Packed => Self::new(order_id, TradeAgreementStatus::Confirmed), 1499 OrderStatus::Completed => Self::new(order_id, TradeAgreementStatus::Confirmed), 1500 OrderStatus::Declined => Self::new(order_id, TradeAgreementStatus::Declined), 1501 OrderStatus::NeedsReview => Self::new(order_id, TradeAgreementStatus::NeedsReview), 1502 }; 1503 1504 match status { 1505 OrderStatus::NeedsAction => {} 1506 OrderStatus::Scheduled => { 1507 projection.inventory = TradeInventoryStatus::Reserved; 1508 } 1509 OrderStatus::Packed => { 1510 projection.inventory = TradeInventoryStatus::Reserved; 1511 } 1512 OrderStatus::Completed => { 1513 projection.inventory = TradeInventoryStatus::Reserved; 1514 } 1515 OrderStatus::Declined => { 1516 projection.inventory = TradeInventoryStatus::Available; 1517 } 1518 OrderStatus::NeedsReview => {} 1519 } 1520 1521 projection 1522 } 1523 1524 pub fn from_buyer_order_status(order_id: OrderId, status: BuyerOrderStatus) -> Self { 1525 let mut projection = match status { 1526 BuyerOrderStatus::Placed => Self::new(order_id, TradeAgreementStatus::Ordered), 1527 BuyerOrderStatus::Scheduled => Self::new(order_id, TradeAgreementStatus::Confirmed), 1528 BuyerOrderStatus::Ready => Self::new(order_id, TradeAgreementStatus::Confirmed), 1529 BuyerOrderStatus::Completed => Self::new(order_id, TradeAgreementStatus::Confirmed), 1530 BuyerOrderStatus::Declined => Self::new(order_id, TradeAgreementStatus::Declined), 1531 BuyerOrderStatus::NeedsReview => Self::new(order_id, TradeAgreementStatus::NeedsReview), 1532 }; 1533 1534 match status { 1535 BuyerOrderStatus::Placed => {} 1536 BuyerOrderStatus::Scheduled => { 1537 projection.inventory = TradeInventoryStatus::Reserved; 1538 } 1539 BuyerOrderStatus::Ready => { 1540 projection.inventory = TradeInventoryStatus::Reserved; 1541 } 1542 BuyerOrderStatus::Completed => { 1543 projection.inventory = TradeInventoryStatus::Reserved; 1544 } 1545 BuyerOrderStatus::Declined => { 1546 projection.inventory = TradeInventoryStatus::Available; 1547 } 1548 BuyerOrderStatus::NeedsReview => {} 1549 } 1550 1551 projection 1552 } 1553 1554 pub fn with_economics(mut self, economics: TradeEconomicsProjection) -> Self { 1555 self.economics = economics; 1556 self 1557 } 1558 1559 pub fn with_revision(mut self, revision: TradeRevisionStatus) -> Self { 1560 self.revision = revision; 1561 self 1562 } 1563 } 1564 1565 pub fn order_status_from_active_order_projection( 1566 projection: &RadrootsOrderProjection, 1567 ) -> Option<OrderStatus> { 1568 match projection.status { 1569 RadrootsOrderStatus::Missing => None, 1570 RadrootsOrderStatus::Requested => Some(OrderStatus::NeedsAction), 1571 RadrootsOrderStatus::Accepted => Some(OrderStatus::Scheduled), 1572 RadrootsOrderStatus::Declined | RadrootsOrderStatus::Cancelled => { 1573 Some(OrderStatus::Declined) 1574 } 1575 RadrootsOrderStatus::Invalid => Some(OrderStatus::NeedsAction), 1576 } 1577 } 1578 1579 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 1580 #[serde(rename_all = "snake_case")] 1581 pub enum OrdersFilter { 1582 All, 1583 #[default] 1584 NeedsAction, 1585 Scheduled, 1586 Packed, 1587 Completed, 1588 } 1589 1590 impl OrdersFilter { 1591 pub const fn storage_key(self) -> &'static str { 1592 match self { 1593 Self::All => "all", 1594 Self::NeedsAction => "needs_action", 1595 Self::Scheduled => "scheduled", 1596 Self::Packed => "packed", 1597 Self::Completed => "completed", 1598 } 1599 } 1600 } 1601 1602 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 1603 pub struct OrdersScreenQueryState { 1604 pub filter: OrdersFilter, 1605 pub fulfillment_window_id: Option<FulfillmentWindowId>, 1606 } 1607 1608 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] 1609 #[serde(rename_all = "snake_case")] 1610 pub enum OrderPrimaryAction { 1611 Review, 1612 } 1613 1614 impl OrderPrimaryAction { 1615 pub const fn storage_key(self) -> &'static str { 1616 match self { 1617 Self::Review => "review", 1618 } 1619 } 1620 } 1621 1622 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 1623 pub struct OrdersListSummary { 1624 pub total_orders: u32, 1625 pub needs_action_orders: u32, 1626 pub scheduled_orders: u32, 1627 pub packed_orders: u32, 1628 } 1629 1630 impl OrdersListSummary { 1631 pub const fn has_orders(&self) -> bool { 1632 self.total_orders > 0 1633 } 1634 } 1635 1636 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 1637 pub struct OrdersListRow { 1638 pub order_id: OrderId, 1639 pub farm_id: FarmId, 1640 pub fulfillment_window_id: Option<FulfillmentWindowId>, 1641 pub order_number: String, 1642 pub customer_display_name: String, 1643 pub fulfillment_window_label: Option<String>, 1644 pub pickup_location_label: Option<String>, 1645 pub status: OrderStatus, 1646 pub workflow: TradeWorkflowProjection, 1647 pub primary_action: Option<OrderPrimaryAction>, 1648 } 1649 1650 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 1651 pub struct OrdersListProjection { 1652 pub summary: OrdersListSummary, 1653 pub rows: Vec<OrdersListRow>, 1654 } 1655 1656 impl OrdersListProjection { 1657 pub fn is_empty(&self) -> bool { 1658 self.rows.is_empty() 1659 } 1660 } 1661 1662 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 1663 pub struct OrderDetailItemRow { 1664 pub title: String, 1665 pub quantity_display: String, 1666 pub unit_price: Option<ProductPricePresentation>, 1667 pub line_total_minor_units: Option<u32>, 1668 } 1669 1670 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 1671 pub struct OrderDetailProjection { 1672 pub order_id: OrderId, 1673 pub farm_id: FarmId, 1674 pub order_number: String, 1675 pub customer_display_name: String, 1676 pub status: OrderStatus, 1677 pub fulfillment_window_id: Option<FulfillmentWindowId>, 1678 pub fulfillment_window_label: Option<String>, 1679 pub pickup_location_label: Option<String>, 1680 pub items: Vec<OrderDetailItemRow>, 1681 pub economics: TradeEconomicsProjection, 1682 pub workflow: TradeWorkflowProjection, 1683 pub validation_receipts: Vec<TradeValidationReceiptProjection>, 1684 pub primary_action: Option<OrderPrimaryAction>, 1685 } 1686 1687 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 1688 pub struct BuyerOrdersListRow { 1689 pub order_id: OrderId, 1690 pub farm_id: FarmId, 1691 pub order_number: String, 1692 pub farm_display_name: String, 1693 pub fulfillment_summary: String, 1694 pub status: BuyerOrderStatus, 1695 pub workflow: TradeWorkflowProjection, 1696 pub repeat_demand: Option<RepeatDemandHandoffProjection>, 1697 } 1698 1699 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 1700 pub struct BuyerOrdersProjection { 1701 pub rows: Vec<BuyerOrdersListRow>, 1702 } 1703 1704 impl BuyerOrdersProjection { 1705 pub fn is_empty(&self) -> bool { 1706 self.rows.is_empty() 1707 } 1708 } 1709 1710 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 1711 pub struct BuyerOrderDetailProjection { 1712 pub order_id: OrderId, 1713 pub farm_id: FarmId, 1714 pub order_number: String, 1715 pub farm_display_name: String, 1716 pub fulfillment_summary: String, 1717 pub status: BuyerOrderStatus, 1718 pub items: Vec<OrderDetailItemRow>, 1719 pub economics: TradeEconomicsProjection, 1720 pub workflow: TradeWorkflowProjection, 1721 pub validation_receipts: Vec<TradeValidationReceiptProjection>, 1722 pub order_note: Option<String>, 1723 pub repeat_demand: Option<RepeatDemandHandoffProjection>, 1724 } 1725 1726 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 1727 pub struct PackDayScreenQueryState { 1728 pub fulfillment_window_id: Option<FulfillmentWindowId>, 1729 } 1730 1731 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 1732 pub struct PackDayProductTotalRow { 1733 pub title: String, 1734 pub quantity_display: String, 1735 } 1736 1737 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 1738 pub struct PackDayPackListRow { 1739 pub title: String, 1740 pub quantity_display: String, 1741 } 1742 1743 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 1744 pub struct PackDayRosterRow { 1745 pub order_id: OrderId, 1746 pub order_number: String, 1747 pub customer_display_name: String, 1748 } 1749 1750 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 1751 pub struct PackDayProjection { 1752 pub fulfillment_window: Option<FulfillmentWindowSummary>, 1753 pub reminders: ReminderFeedProjection, 1754 pub totals_by_product: Vec<PackDayProductTotalRow>, 1755 pub pack_list: Vec<PackDayPackListRow>, 1756 pub pickup_roster: Vec<PackDayRosterRow>, 1757 } 1758 1759 impl PackDayProjection { 1760 pub fn is_empty(&self) -> bool { 1761 self.totals_by_product.is_empty() 1762 && self.pack_list.is_empty() 1763 && self.pickup_roster.is_empty() 1764 } 1765 } 1766 1767 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 1768 pub struct FarmSummary { 1769 pub farm_id: FarmId, 1770 pub display_name: String, 1771 pub readiness: FarmReadiness, 1772 } 1773 1774 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 1775 #[serde(rename_all = "snake_case")] 1776 pub enum FarmSetupReadiness { 1777 #[default] 1778 NotStarted, 1779 InProgress, 1780 Ready, 1781 } 1782 1783 impl FarmSetupReadiness { 1784 pub const fn storage_key(self) -> &'static str { 1785 match self { 1786 Self::NotStarted => "not_started", 1787 Self::InProgress => "in_progress", 1788 Self::Ready => "ready", 1789 } 1790 } 1791 } 1792 1793 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] 1794 #[serde(rename_all = "snake_case")] 1795 pub enum FarmSetupSection { 1796 Farm, 1797 Location, 1798 OrderMethods, 1799 } 1800 1801 impl FarmSetupSection { 1802 pub const fn ordered() -> [Self; 3] { 1803 [Self::Farm, Self::Location, Self::OrderMethods] 1804 } 1805 1806 pub const fn storage_key(self) -> &'static str { 1807 match self { 1808 Self::Farm => "farm", 1809 Self::Location => "location", 1810 Self::OrderMethods => "order_methods", 1811 } 1812 } 1813 } 1814 1815 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] 1816 #[serde(rename_all = "snake_case")] 1817 pub enum FarmSetupBlocker { 1818 AddFarmName, 1819 AddLocationOrServiceArea, 1820 ChooseOrderMethod, 1821 } 1822 1823 impl FarmSetupBlocker { 1824 pub const fn storage_key(self) -> &'static str { 1825 match self { 1826 Self::AddFarmName => "add_farm_name", 1827 Self::AddLocationOrServiceArea => "add_location_or_service_area", 1828 Self::ChooseOrderMethod => "choose_order_method", 1829 } 1830 } 1831 } 1832 1833 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 1834 pub struct FarmSetupDraft { 1835 pub farm_name: String, 1836 pub location_or_service_area: String, 1837 pub order_methods: BTreeSet<FarmOrderMethod>, 1838 } 1839 1840 impl FarmSetupDraft { 1841 pub fn new( 1842 farm_name: impl Into<String>, 1843 location_or_service_area: impl Into<String>, 1844 order_methods: impl IntoIterator<Item = FarmOrderMethod>, 1845 ) -> Self { 1846 Self { 1847 farm_name: farm_name.into(), 1848 location_or_service_area: location_or_service_area.into(), 1849 order_methods: order_methods.into_iter().collect(), 1850 } 1851 } 1852 1853 pub fn blockers(&self) -> Vec<FarmSetupBlocker> { 1854 let mut blockers = Vec::new(); 1855 1856 if self.farm_name.trim().is_empty() { 1857 blockers.push(FarmSetupBlocker::AddFarmName); 1858 } 1859 1860 if self.location_or_service_area.trim().is_empty() { 1861 blockers.push(FarmSetupBlocker::AddLocationOrServiceArea); 1862 } 1863 1864 if self.order_methods.is_empty() { 1865 blockers.push(FarmSetupBlocker::ChooseOrderMethod); 1866 } 1867 1868 blockers 1869 } 1870 1871 pub fn readiness(&self) -> FarmSetupReadiness { 1872 let blockers = self.blockers(); 1873 if blockers.is_empty() { 1874 FarmSetupReadiness::Ready 1875 } else if self.is_empty() { 1876 FarmSetupReadiness::NotStarted 1877 } else { 1878 FarmSetupReadiness::InProgress 1879 } 1880 } 1881 1882 pub fn is_empty(&self) -> bool { 1883 self.farm_name.trim().is_empty() 1884 && self.location_or_service_area.trim().is_empty() 1885 && self.order_methods.is_empty() 1886 } 1887 } 1888 1889 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 1890 pub struct FarmSetupProjection { 1891 pub draft: FarmSetupDraft, 1892 pub saved_farm: Option<FarmSummary>, 1893 pub readiness: FarmSetupReadiness, 1894 pub blockers: Vec<FarmSetupBlocker>, 1895 } 1896 1897 impl Default for FarmSetupProjection { 1898 fn default() -> Self { 1899 Self::not_started() 1900 } 1901 } 1902 1903 impl FarmSetupProjection { 1904 pub fn new(draft: FarmSetupDraft, saved_farm: Option<FarmSummary>) -> Self { 1905 match saved_farm { 1906 Some(saved_farm) => Self { 1907 draft, 1908 saved_farm: Some(saved_farm), 1909 readiness: FarmSetupReadiness::Ready, 1910 blockers: Vec::new(), 1911 }, 1912 None => Self::from_draft(draft), 1913 } 1914 } 1915 1916 pub fn not_started() -> Self { 1917 Self::from_draft(FarmSetupDraft::default()) 1918 } 1919 1920 pub fn from_draft(draft: FarmSetupDraft) -> Self { 1921 let readiness = draft.readiness(); 1922 let blockers = draft.blockers(); 1923 1924 Self { 1925 draft, 1926 saved_farm: None, 1927 readiness, 1928 blockers, 1929 } 1930 } 1931 1932 pub fn from_saved_farm(saved_farm: FarmSummary) -> Self { 1933 Self { 1934 draft: FarmSetupDraft::default(), 1935 saved_farm: Some(saved_farm), 1936 readiness: FarmSetupReadiness::Ready, 1937 blockers: Vec::new(), 1938 } 1939 } 1940 1941 pub const fn has_saved_farm(&self) -> bool { 1942 self.saved_farm.is_some() 1943 } 1944 } 1945 1946 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 1947 pub struct FulfillmentWindowSummary { 1948 pub fulfillment_window_id: FulfillmentWindowId, 1949 pub farm_id: FarmId, 1950 pub starts_at: String, 1951 pub ends_at: String, 1952 } 1953 1954 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 1955 pub struct TodaySummary { 1956 pub farm_id: FarmId, 1957 pub orders_needing_action: u32, 1958 pub low_stock_products: u32, 1959 pub draft_products: u32, 1960 pub reminders_due_soon: u32, 1961 } 1962 1963 impl TodaySummary { 1964 pub const fn has_attention_items(&self) -> bool { 1965 self.orders_needing_action > 0 1966 || self.low_stock_products > 0 1967 || self.draft_products > 0 1968 || self.reminders_due_soon > 0 1969 } 1970 } 1971 1972 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 1973 pub struct ReminderDeadlineProjection { 1974 pub reminder_id: ReminderId, 1975 pub farm_id: FarmId, 1976 pub order_id: Option<OrderId>, 1977 pub fulfillment_window_id: Option<FulfillmentWindowId>, 1978 pub kind: ReminderKind, 1979 pub surface: ReminderSurface, 1980 pub urgency: ReminderUrgency, 1981 pub title: String, 1982 pub detail: String, 1983 pub deadline_at: String, 1984 pub action_label: Option<String>, 1985 pub delivery_state: ReminderDeliveryState, 1986 } 1987 1988 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 1989 pub struct ReminderFeedProjection { 1990 pub items: Vec<ReminderDeadlineProjection>, 1991 } 1992 1993 impl ReminderFeedProjection { 1994 pub fn is_empty(&self) -> bool { 1995 self.items.is_empty() 1996 } 1997 1998 pub fn due_soon_count(&self) -> usize { 1999 self.items 2000 .iter() 2001 .filter(|item| { 2002 matches!( 2003 item.urgency, 2004 ReminderUrgency::DueSoon | ReminderUrgency::Overdue | ReminderUrgency::Blocking 2005 ) 2006 }) 2007 .count() 2008 } 2009 } 2010 2011 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 2012 pub struct ReminderLogEntryProjection { 2013 pub reminder_id: ReminderId, 2014 pub kind: ReminderKind, 2015 pub title: String, 2016 pub recorded_at: String, 2017 pub delivery_state: ReminderDeliveryState, 2018 pub detail: Option<String>, 2019 } 2020 2021 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 2022 pub struct ReminderLogProjection { 2023 pub entries: Vec<ReminderLogEntryProjection>, 2024 } 2025 2026 impl ReminderLogProjection { 2027 pub fn is_empty(&self) -> bool { 2028 self.entries.is_empty() 2029 } 2030 } 2031 2032 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 2033 pub struct RepeatDemandHandoffProjection { 2034 pub order_id: OrderId, 2035 pub farm_id: FarmId, 2036 pub eligibility: RepeatDemandEligibility, 2037 pub available_item_count: u32, 2038 pub unavailable_item_count: u32, 2039 } 2040 2041 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 2042 pub struct ProductListRow { 2043 pub product_id: ProductId, 2044 pub farm_id: FarmId, 2045 pub title: String, 2046 pub status: ProductStatus, 2047 pub stock_count: u32, 2048 } 2049 2050 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 2051 pub struct OrderListRow { 2052 pub order_id: OrderId, 2053 pub farm_id: FarmId, 2054 pub fulfillment_window_id: Option<FulfillmentWindowId>, 2055 pub order_number: String, 2056 pub customer_display_name: String, 2057 pub status: OrderStatus, 2058 } 2059 2060 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] 2061 #[serde(rename_all = "snake_case")] 2062 pub enum TodaySetupTaskKind { 2063 CompleteFarmProfile, 2064 AddPickupLocation, 2065 AddOperatingRules, 2066 AddFulfillmentWindow, 2067 ResolveAvailabilityConflicts, 2068 PublishProduct, 2069 } 2070 2071 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 2072 pub struct TodaySetupTask { 2073 pub kind: TodaySetupTaskKind, 2074 pub is_complete: bool, 2075 } 2076 2077 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 2078 pub struct TodayAgendaProjection { 2079 pub farm: Option<FarmSummary>, 2080 pub summary: Option<TodaySummary>, 2081 pub reminders: ReminderFeedProjection, 2082 pub orders_needing_action: Vec<OrderListRow>, 2083 pub low_stock_products: Vec<ProductListRow>, 2084 pub draft_products: Vec<ProductListRow>, 2085 pub next_fulfillment_window: Option<FulfillmentWindowSummary>, 2086 pub setup_checklist: Vec<TodaySetupTask>, 2087 } 2088 2089 impl TodayAgendaProjection { 2090 pub fn has_attention_items(&self) -> bool { 2091 self.summary 2092 .as_ref() 2093 .is_some_and(TodaySummary::has_attention_items) 2094 || !self.reminders.is_empty() 2095 || !self.orders_needing_action.is_empty() 2096 || !self.low_stock_products.is_empty() 2097 || !self.draft_products.is_empty() 2098 } 2099 2100 pub fn needs_setup(&self) -> bool { 2101 self.setup_checklist.iter().any(|item| !item.is_complete) 2102 } 2103 } 2104 2105 #[cfg(test)] 2106 mod tests { 2107 use radroots_core::{ 2108 RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, 2109 }; 2110 use radroots_events::ids::{ 2111 RadrootsEventId, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId, 2112 RadrootsOrderQuoteId, RadrootsPublicKey, 2113 }; 2114 use radroots_events::order::{ 2115 RadrootsOrderEconomicItem, RadrootsOrderEconomics, RadrootsOrderPricingBasis, 2116 }; 2117 use radroots_trade::order::{RadrootsOrderProjection, RadrootsOrderStatus}; 2118 use radroots_trade::validation_receipt::{ 2119 RadrootsValidationReceiptProofSystem, RadrootsValidationReceiptResult, 2120 RadrootsValidationReceiptType, 2121 }; 2122 2123 use super::{ 2124 AccountCustody, AccountSummary, AccountSurfaceActivationProjection, ActiveSurface, 2125 ActivityEventId, AppActivityContext, AppActivityEvent, AppActivityKind, 2126 AppIdentityProjection, AppStartupGate, BlackoutPeriodId, BuyerCartLineProjection, 2127 BuyerCartProjection, BuyerContext, BuyerListingRow, BuyerListingsProjection, 2128 BuyerOrderDetailProjection, BuyerOrderReviewDisabledReason, BuyerOrderReviewDraft, 2129 BuyerOrderReviewProjection, BuyerOrderReviewSummaryProjection, BuyerOrderStatus, 2130 BuyerOrdersListRow, BuyerOrdersProjection, FarmId, FarmOrderMethod, FarmReadinessBlocker, 2131 FarmRulesProjection, FarmRulesReadiness, FarmSetupBlocker, FarmSetupDraft, 2132 FarmSetupProjection, FarmSetupReadiness, FarmSetupSection, FarmTimingConflict, 2133 FarmTimingConflictKind, FarmerActivationProjection, FarmerSection, FulfillmentWindowId, 2134 IdentityBlockedReason, IdentityReadiness, LoggedOutStartupPhase, 2135 LoggedOutStartupProjection, OrderDetailItemRow, OrderDetailProjection, OrderId, 2136 OrderListRow, OrderPrimaryAction, OrderStatus, OrdersFilter, OrdersListProjection, 2137 OrdersListRow, OrdersListSummary, OrdersScreenQueryState, PackDayBatchPrintArtifact, 2138 PackDayBatchPrintFailureKind, PackDayBatchPrintStatus, PackDayExportArtifact, 2139 PackDayExportArtifactKind, PackDayExportBundle, PackDayExportInstanceId, 2140 PackDayExportStatus, PackDayHostHandoffKind, PackDayHostHandoffStatus, 2141 PackDayOutputCustomerOrder, PackDayOutputOrderState, PackDayOutputPackListEntry, 2142 PackDayOutputProductTotal, PackDayOutputQuantity, PackDayOutputSource, PackDayOutputWindow, 2143 PackDayPackListRow, PackDayPrintFailureKind, PackDayPrintKind, PackDayPrintLabelStock, 2144 PackDayPrintStatus, PackDayProductTotalRow, PackDayProjection, PackDayRosterRow, 2145 PackDayScreenQueryState, ParseStartupSignerSourceError, PersonalEntryProjection, 2146 PersonalEntryState, PersonalSection, PickupLocationId, ProductAttentionState, 2147 ProductAvailabilityState, ProductAvailabilitySummary, ProductEditorDraft, ProductListRow, 2148 ProductPricePresentation, ProductPublishBlocker, ProductStatus, ProductStockState, 2149 ProductStockSummary, ProductsFilter, ProductsListProjection, ProductsListRow, 2150 ProductsListSummary, ProductsSort, ReminderDeadlineProjection, ReminderDeliveryState, 2151 ReminderFeedProjection, ReminderId, ReminderKind, ReminderLogEntryProjection, 2152 ReminderLogProjection, ReminderSurface, ReminderUrgency, RepeatDemandEligibility, 2153 RepeatDemandHandoffProjection, SelectedAccountProjection, SelectedSurfaceProjection, 2154 SettingsPreference, SettingsSection, ShellSection, StartupSignerEntryProjection, 2155 StartupSignerSource, StartupSignerSourceKind, TodayAgendaProjection, TodaySetupTask, 2156 TodaySetupTaskKind, TodaySummary, TradeAgreementStatus, TradeEconomicsProjection, 2157 TradeInventoryStatus, TradeProvenanceProjection, TradeRevisionStatus, 2158 TradeValidationReceiptProofSystem, TradeValidationReceiptResult, 2159 TradeValidationReceiptType, TradeWorkflowProjection, TradeWorkflowSource, 2160 order_status_from_active_order_projection, 2161 }; 2162 use std::{collections::BTreeSet, str::FromStr}; 2163 use uuid::Uuid; 2164 2165 #[test] 2166 fn shell_section_storage_keys_are_unique_and_round_trip() { 2167 let sections = [ 2168 ShellSection::Home, 2169 ShellSection::Personal(PersonalSection::Browse), 2170 ShellSection::Personal(PersonalSection::Search), 2171 ShellSection::Personal(PersonalSection::Cart), 2172 ShellSection::Personal(PersonalSection::Orders), 2173 ShellSection::Account, 2174 ShellSection::Farmer(FarmerSection::Today), 2175 ShellSection::Farmer(FarmerSection::Products), 2176 ShellSection::Farmer(FarmerSection::Orders), 2177 ShellSection::Farmer(FarmerSection::PackDay), 2178 ShellSection::Farmer(FarmerSection::Farm), 2179 ShellSection::Settings(SettingsSection::Account), 2180 ShellSection::Settings(SettingsSection::Farm), 2181 ShellSection::Settings(SettingsSection::Settings), 2182 ShellSection::Settings(SettingsSection::About), 2183 ]; 2184 let keys = sections 2185 .into_iter() 2186 .map(ShellSection::storage_key) 2187 .collect::<BTreeSet<_>>(); 2188 2189 assert_eq!(keys.len(), sections.len()); 2190 2191 for section in sections { 2192 let parsed = 2193 ShellSection::from_str(section.storage_key()).expect("section should parse"); 2194 assert_eq!(parsed, section); 2195 } 2196 } 2197 2198 #[test] 2199 fn shell_section_surface_is_explicit_for_surface_routes_only() { 2200 assert_eq!(ShellSection::Home.surface(), None); 2201 assert_eq!(ShellSection::Account.surface(), None); 2202 assert_eq!( 2203 ShellSection::Personal(PersonalSection::Browse).surface(), 2204 Some(ActiveSurface::Personal) 2205 ); 2206 assert_eq!( 2207 ShellSection::Farmer(FarmerSection::Today).surface(), 2208 Some(ActiveSurface::Farmer) 2209 ); 2210 assert_eq!( 2211 ShellSection::Settings(SettingsSection::Settings).surface(), 2212 None 2213 ); 2214 } 2215 2216 #[test] 2217 fn shell_section_default_for_surface_preserves_current_farmer_entry() { 2218 assert_eq!( 2219 ShellSection::default_for_surface(ActiveSurface::Personal), 2220 ShellSection::Personal(PersonalSection::Browse) 2221 ); 2222 assert_eq!( 2223 ShellSection::default_for_surface(ActiveSurface::Farmer), 2224 ShellSection::Farmer(FarmerSection::Today) 2225 ); 2226 } 2227 2228 #[test] 2229 fn selected_surface_defaults_to_personal() { 2230 assert_eq!( 2231 SelectedSurfaceProjection::default().active_surface, 2232 ActiveSurface::Personal 2233 ); 2234 } 2235 2236 #[test] 2237 fn selected_account_without_farmer_activation_falls_back_to_personal_surface() { 2238 let projection = SelectedAccountProjection::new( 2239 AccountSummary { 2240 account_id: "acct_01".to_owned(), 2241 npub: "npub1example".to_owned(), 2242 label: Some("North field".to_owned()), 2243 custody: AccountCustody::LocalManaged, 2244 }, 2245 SelectedSurfaceProjection::new(ActiveSurface::Farmer), 2246 FarmerActivationProjection::inactive(), 2247 ); 2248 2249 assert_eq!(projection.active_surface(), ActiveSurface::Personal); 2250 assert!(!projection.farmer_activation.is_active()); 2251 } 2252 2253 #[test] 2254 fn account_surface_activation_projection_normalizes_to_personal_without_farm_binding() { 2255 let projection = AccountSurfaceActivationProjection::new( 2256 "acct_04", 2257 SelectedSurfaceProjection::new(ActiveSurface::Farmer), 2258 FarmerActivationProjection::inactive(), 2259 ); 2260 2261 assert_eq!(projection.account_id, "acct_04"); 2262 assert_eq!(projection.active_surface(), ActiveSurface::Personal); 2263 assert!(!projection.farmer_activation.is_active()); 2264 } 2265 2266 #[test] 2267 fn selected_account_projection_round_trips_through_surface_activation_state() { 2268 let selected_account = SelectedAccountProjection::new( 2269 AccountSummary { 2270 account_id: "acct_roundtrip".to_owned(), 2271 npub: "npub1roundtrip".to_owned(), 2272 label: Some("Roundtrip".to_owned()), 2273 custody: AccountCustody::LocalManaged, 2274 }, 2275 SelectedSurfaceProjection::new(ActiveSurface::Farmer), 2276 FarmerActivationProjection::active(FarmId::new()), 2277 ); 2278 let activation = AccountSurfaceActivationProjection::from(&selected_account); 2279 let restored = SelectedAccountProjection::from_surface_activation( 2280 selected_account.account.clone(), 2281 activation, 2282 ); 2283 2284 assert_eq!(restored, selected_account); 2285 } 2286 2287 #[test] 2288 fn startup_gate_tracks_setup_personal_farmer_and_blocked_states() { 2289 let farmer_identity = AppIdentityProjection::ready( 2290 Vec::new(), 2291 SelectedAccountProjection::new( 2292 AccountSummary { 2293 account_id: "acct_02".to_owned(), 2294 npub: "npub1farmer".to_owned(), 2295 label: None, 2296 custody: AccountCustody::LocalManaged, 2297 }, 2298 SelectedSurfaceProjection::new(ActiveSurface::Farmer), 2299 FarmerActivationProjection::active(FarmId::new()), 2300 ), 2301 ); 2302 let personal_identity = AppIdentityProjection::ready( 2303 Vec::new(), 2304 SelectedAccountProjection::new( 2305 AccountSummary { 2306 account_id: "acct_03".to_owned(), 2307 npub: "npub1personal".to_owned(), 2308 label: None, 2309 custody: AccountCustody::LocalManaged, 2310 }, 2311 SelectedSurfaceProjection::new(ActiveSurface::Personal), 2312 FarmerActivationProjection::inactive(), 2313 ), 2314 ); 2315 2316 assert_eq!( 2317 AppIdentityProjection::missing().startup_gate(), 2318 AppStartupGate::SetupRequired 2319 ); 2320 assert_eq!(personal_identity.startup_gate(), AppStartupGate::Personal); 2321 assert_eq!(farmer_identity.startup_gate(), AppStartupGate::Farmer); 2322 assert_eq!( 2323 AppIdentityProjection::blocked(IdentityBlockedReason::HostVaultUnavailable) 2324 .startup_gate(), 2325 AppStartupGate::Blocked 2326 ); 2327 } 2328 2329 #[test] 2330 fn ready_identity_keeps_selected_account_visible_in_roster() { 2331 let selected_account = SelectedAccountProjection::new( 2332 AccountSummary { 2333 account_id: "acct_selected".to_owned(), 2334 npub: "npub1selected".to_owned(), 2335 label: None, 2336 custody: AccountCustody::RemoteSigner, 2337 }, 2338 SelectedSurfaceProjection::new(ActiveSurface::Personal), 2339 FarmerActivationProjection::inactive(), 2340 ); 2341 let projection = AppIdentityProjection::ready(Vec::new(), selected_account.clone()); 2342 2343 assert_eq!(projection.readiness.storage_key(), "ready"); 2344 assert_eq!(projection.roster.len(), 1); 2345 assert_eq!(projection.roster[0], selected_account.account); 2346 assert_eq!(projection.selected_account, Some(selected_account)); 2347 } 2348 2349 #[test] 2350 fn blocked_identity_keeps_selected_account_visible_in_roster() { 2351 let selected_account = SelectedAccountProjection::new( 2352 AccountSummary { 2353 account_id: "acct_blocked".to_owned(), 2354 npub: "npub1blocked".to_owned(), 2355 label: Some("Blocked account".to_owned()), 2356 custody: AccountCustody::LocalManaged, 2357 }, 2358 SelectedSurfaceProjection::new(ActiveSurface::Personal), 2359 FarmerActivationProjection::inactive(), 2360 ); 2361 let projection = AppIdentityProjection::blocked_with_selection( 2362 IdentityBlockedReason::HostVaultUnavailable, 2363 Vec::new(), 2364 Some(selected_account.clone()), 2365 ); 2366 2367 assert_eq!( 2368 projection.readiness, 2369 IdentityReadiness::Blocked(IdentityBlockedReason::HostVaultUnavailable) 2370 ); 2371 assert_eq!(projection.roster, vec![selected_account.account.clone()]); 2372 assert_eq!(projection.selected_account, Some(selected_account)); 2373 assert_eq!(projection.startup_gate(), AppStartupGate::Blocked); 2374 } 2375 2376 #[test] 2377 fn missing_identity_can_keep_roster_visible_without_selected_account() { 2378 let roster = vec![AccountSummary { 2379 account_id: "acct_waiting".to_owned(), 2380 npub: "npub1waiting".to_owned(), 2381 label: Some("Waiting".to_owned()), 2382 custody: AccountCustody::LocalManaged, 2383 }]; 2384 let projection = AppIdentityProjection::missing_with_roster(roster.clone()); 2385 2386 assert_eq!(projection.readiness, IdentityReadiness::MissingAccount); 2387 assert_eq!(projection.roster, roster); 2388 assert!(projection.selected_account.is_none()); 2389 assert_eq!(projection.startup_gate(), AppStartupGate::SetupRequired); 2390 } 2391 2392 #[test] 2393 fn personal_entry_projection_is_derived_from_identity_truth() { 2394 let guest_identity = AppIdentityProjection::missing(); 2395 let selected_account = SelectedAccountProjection::new( 2396 AccountSummary { 2397 account_id: "acct_farmer".to_owned(), 2398 npub: "npub1farmer".to_owned(), 2399 label: Some("Field stand".to_owned()), 2400 custody: AccountCustody::LocalManaged, 2401 }, 2402 SelectedSurfaceProjection::new(ActiveSurface::Farmer), 2403 FarmerActivationProjection::active(FarmId::new()), 2404 ); 2405 let signed_in_identity = AppIdentityProjection::ready(Vec::new(), selected_account.clone()); 2406 let blocked_identity = AppIdentityProjection::blocked_with_selection( 2407 IdentityBlockedReason::HostVaultUnavailable, 2408 Vec::new(), 2409 Some(selected_account.clone()), 2410 ); 2411 2412 assert_eq!( 2413 guest_identity.personal_entry(), 2414 PersonalEntryProjection::guest() 2415 ); 2416 assert_eq!( 2417 guest_identity.personal_entry().state.storage_key(), 2418 PersonalEntryState::Guest.storage_key() 2419 ); 2420 assert_eq!( 2421 signed_in_identity.personal_entry(), 2422 PersonalEntryProjection::signed_in(selected_account.clone()) 2423 ); 2424 assert!( 2425 signed_in_identity 2426 .personal_entry() 2427 .can_enter_farmer_workspace 2428 ); 2429 assert_eq!( 2430 blocked_identity.personal_entry(), 2431 PersonalEntryProjection::blocked(Some(selected_account)) 2432 ); 2433 } 2434 2435 #[test] 2436 fn buyer_context_defaults_to_guest_and_tracks_selected_account() { 2437 let selected_account = SelectedAccountProjection::new( 2438 AccountSummary { 2439 account_id: "acct_buyer".to_owned(), 2440 npub: "npub1buyer".to_owned(), 2441 label: Some("Buyer".to_owned()), 2442 custody: AccountCustody::LocalManaged, 2443 }, 2444 SelectedSurfaceProjection::new(ActiveSurface::Personal), 2445 FarmerActivationProjection::inactive(), 2446 ); 2447 let ready_identity = AppIdentityProjection::ready(Vec::new(), selected_account); 2448 2449 assert_eq!(BuyerContext::guest().storage_key(), "guest"); 2450 assert_eq!( 2451 BuyerContext::account("acct_buyer").storage_key(), 2452 "account:acct_buyer" 2453 ); 2454 assert_eq!( 2455 AppIdentityProjection::missing().buyer_context(), 2456 BuyerContext::Guest 2457 ); 2458 assert_eq!( 2459 ready_identity.buyer_context(), 2460 BuyerContext::account("acct_buyer") 2461 ); 2462 } 2463 2464 #[test] 2465 fn logged_out_startup_defaults_to_continue_prompt_with_empty_signer_entry() { 2466 assert_eq!( 2467 LoggedOutStartupProjection::default(), 2468 LoggedOutStartupProjection { 2469 phase: LoggedOutStartupPhase::ContinuePrompt, 2470 signer_entry: StartupSignerEntryProjection::default(), 2471 } 2472 ); 2473 } 2474 2475 #[test] 2476 fn logged_out_startup_phase_and_signer_source_kind_storage_keys_are_stable() { 2477 assert_eq!( 2478 LoggedOutStartupPhase::ContinuePrompt.storage_key(), 2479 "continue_prompt" 2480 ); 2481 assert_eq!( 2482 LoggedOutStartupPhase::IdentityChoice.storage_key(), 2483 "identity_choice" 2484 ); 2485 assert_eq!( 2486 LoggedOutStartupPhase::GenerateKeyStarting.storage_key(), 2487 "generate_key_starting" 2488 ); 2489 assert_eq!( 2490 LoggedOutStartupPhase::SignerEntry.storage_key(), 2491 "signer_entry" 2492 ); 2493 assert_eq!( 2494 StartupSignerSourceKind::BunkerUri.storage_key(), 2495 "bunker_uri" 2496 ); 2497 assert_eq!( 2498 StartupSignerSourceKind::DiscoveryUrl.storage_key(), 2499 "discovery_url" 2500 ); 2501 } 2502 2503 #[test] 2504 fn startup_signer_source_parses_direct_bunker_uri_and_discovery_url() { 2505 let bunker_uri = 2506 "bunker://npub1signer?relay=wss%3A%2F%2Frelay.radroots.example&secret=test-secret"; 2507 let discovery_url = 2508 format!("https://signer.radroots.example/connect?uri={bunker_uri}&label=field"); 2509 2510 let bunker_source = bunker_uri 2511 .parse::<StartupSignerSource>() 2512 .expect("bunker uri should parse"); 2513 let discovery_source = discovery_url 2514 .parse::<StartupSignerSource>() 2515 .expect("discovery url should parse"); 2516 2517 assert_eq!( 2518 bunker_source, 2519 StartupSignerSource::BunkerUri(bunker_uri.to_owned()) 2520 ); 2521 assert_eq!(bunker_source.kind(), StartupSignerSourceKind::BunkerUri); 2522 assert_eq!(bunker_source.value(), bunker_uri); 2523 assert_eq!( 2524 discovery_source, 2525 StartupSignerSource::DiscoveryUrl(discovery_url.clone()) 2526 ); 2527 assert_eq!( 2528 discovery_source.kind(), 2529 StartupSignerSourceKind::DiscoveryUrl 2530 ); 2531 assert_eq!(discovery_source.value(), discovery_url); 2532 } 2533 2534 #[test] 2535 fn startup_signer_source_rejects_empty_client_uri_and_missing_discovery_uri() { 2536 assert_eq!( 2537 "".parse::<StartupSignerSource>(), 2538 Err(ParseStartupSignerSourceError::EmptyInput) 2539 ); 2540 assert_eq!( 2541 "nostrconnect://npub1client?relay=wss%3A%2F%2Frelay.radroots.example&secret=test" 2542 .parse::<StartupSignerSource>(), 2543 Err(ParseStartupSignerSourceError::UnsupportedClientUri) 2544 ); 2545 assert_eq!( 2546 "https://signer.radroots.example/connect".parse::<StartupSignerSource>(), 2547 Err(ParseStartupSignerSourceError::MissingDiscoveryUri) 2548 ); 2549 assert_eq!( 2550 "not a signer source".parse::<StartupSignerSource>(), 2551 Err(ParseStartupSignerSourceError::UnsupportedSource) 2552 ); 2553 } 2554 2555 #[test] 2556 fn signer_entry_projection_exposes_the_typed_source_contract() { 2557 let mut projection = StartupSignerEntryProjection::new( 2558 " bunker://npub1signer?relay=wss%3A%2F%2Frelay.radroots.example ", 2559 ); 2560 2561 assert_eq!( 2562 projection.parsed_source(), 2563 Ok(StartupSignerSource::BunkerUri( 2564 "bunker://npub1signer?relay=wss%3A%2F%2Frelay.radroots.example".to_owned() 2565 )) 2566 ); 2567 2568 projection.set_source_input("https://signer.radroots.example/connect?uri=bunker://npub1"); 2569 assert_eq!( 2570 projection.parsed_source(), 2571 Ok(StartupSignerSource::DiscoveryUrl( 2572 "https://signer.radroots.example/connect?uri=bunker://npub1".to_owned() 2573 )) 2574 ); 2575 } 2576 2577 #[test] 2578 fn typed_ids_round_trip_through_strings() { 2579 let uuid = Uuid::parse_str("018f4d61-19b0-7cc4-9d4e-6d0df7c0aa11") 2580 .expect("test uuid should parse"); 2581 let farm_id = FarmId::from(uuid); 2582 let parsed = FarmId::from_str(&farm_id.to_string()).expect("farm id should parse"); 2583 2584 assert_eq!(parsed, farm_id); 2585 assert_eq!(parsed.as_uuid(), uuid); 2586 } 2587 2588 #[test] 2589 fn product_status_filter_and_sort_storage_keys_are_stable() { 2590 assert_eq!(ProductStatus::Draft.storage_key(), "draft"); 2591 assert_eq!(ProductStatus::Published.storage_key(), "published"); 2592 assert_eq!(ProductStatus::Paused.storage_key(), "paused"); 2593 assert_eq!(ProductStatus::Archived.storage_key(), "archived"); 2594 assert!(ProductStatus::Published.is_live()); 2595 assert!(!ProductStatus::Draft.is_live()); 2596 2597 assert_eq!(ProductsFilter::default(), ProductsFilter::All); 2598 assert_eq!(ProductsFilter::All.storage_key(), "all"); 2599 assert_eq!(ProductsFilter::Live.storage_key(), "live"); 2600 assert_eq!(ProductsFilter::Drafts.storage_key(), "drafts"); 2601 assert_eq!( 2602 ProductsFilter::NeedAttention.storage_key(), 2603 "need_attention" 2604 ); 2605 assert_eq!(ProductsFilter::Paused.storage_key(), "paused"); 2606 assert_eq!(ProductsFilter::Archived.storage_key(), "archived"); 2607 2608 assert_eq!(ProductsSort::default(), ProductsSort::Updated); 2609 assert_eq!(ProductsSort::Updated.storage_key(), "updated"); 2610 assert_eq!(ProductsSort::Name.storage_key(), "name"); 2611 assert_eq!(ProductsSort::Availability.storage_key(), "availability"); 2612 assert_eq!(ProductsSort::Stock.storage_key(), "stock"); 2613 assert_eq!(ProductsSort::Price.storage_key(), "price"); 2614 } 2615 2616 #[test] 2617 fn buyer_order_review_disabled_reason_storage_keys_are_stable() { 2618 assert_eq!( 2619 BuyerOrderReviewDisabledReason::EmptyCart.storage_key(), 2620 "empty_cart" 2621 ); 2622 assert_eq!( 2623 BuyerOrderReviewDisabledReason::MissingFulfillment.storage_key(), 2624 "missing_fulfillment" 2625 ); 2626 assert_eq!( 2627 BuyerOrderReviewDisabledReason::MissingName.storage_key(), 2628 "missing_name" 2629 ); 2630 assert_eq!( 2631 BuyerOrderReviewDisabledReason::MissingEmail.storage_key(), 2632 "missing_email" 2633 ); 2634 assert_eq!( 2635 BuyerOrderReviewDisabledReason::AccountRequired.storage_key(), 2636 "account_required" 2637 ); 2638 } 2639 2640 #[test] 2641 fn product_attention_stock_and_projection_states_are_explicit() { 2642 let row = ProductsListRow { 2643 product_id: super::ProductId::new(), 2644 farm_id: FarmId::new(), 2645 title: "Pea shoots".to_owned(), 2646 subtitle: Some("Tray-grown".to_owned()), 2647 status: ProductStatus::Draft, 2648 attention_state: ProductAttentionState::MissingAvailability, 2649 availability: ProductAvailabilitySummary { 2650 state: ProductAvailabilityState::MissingWindow, 2651 label: "Missing window".to_owned(), 2652 }, 2653 stock: ProductStockSummary { 2654 quantity: None, 2655 unit_label: None, 2656 state: ProductStockState::Unset, 2657 }, 2658 price: Some(ProductPricePresentation { 2659 amount_minor_units: 300, 2660 currency_code: "USD".to_owned(), 2661 unit_label: "bag".to_owned(), 2662 }), 2663 updated_at: "2026-04-18T10:00:00Z".to_owned(), 2664 }; 2665 let summary = ProductsListSummary { 2666 total_products: 1, 2667 live_products: 0, 2668 draft_products: 1, 2669 need_attention_products: 1, 2670 }; 2671 let projection = ProductsListProjection { 2672 summary: summary.clone(), 2673 rows: vec![row.clone()], 2674 }; 2675 2676 assert_eq!(ProductAttentionState::LowStock.storage_key(), "low_stock"); 2677 assert!(ProductAttentionState::LowStock.requires_attention()); 2678 assert!(!ProductAttentionState::Healthy.requires_attention()); 2679 assert_eq!( 2680 ProductAvailabilityState::MissingWindow.storage_key(), 2681 "missing_window" 2682 ); 2683 assert_eq!(ProductStockState::SoldOut.storage_key(), "sold_out"); 2684 assert!(ProductStockState::SoldOut.requires_attention()); 2685 assert!(!ProductStockState::InStock.requires_attention()); 2686 assert!(row.requires_attention()); 2687 assert!(summary.has_products()); 2688 assert!(!projection.is_empty()); 2689 assert_eq!(projection.rows[0].availability.label, "Missing window"); 2690 } 2691 2692 #[test] 2693 fn product_editor_publish_blockers_are_explicit_and_minimal() { 2694 let empty_draft = ProductEditorDraft::default(); 2695 let ready_draft = ProductEditorDraft { 2696 title: "Heirloom tomatoes".to_owned(), 2697 subtitle: "Brandywine".to_owned(), 2698 category: "vegetables".to_owned(), 2699 unit_label: "lb".to_owned(), 2700 price_minor_units: Some(450), 2701 price_currency: "USD".to_owned(), 2702 stock_quantity: Some(12), 2703 availability_window_id: Some(super::FulfillmentWindowId::new()), 2704 status: ProductStatus::Draft, 2705 }; 2706 2707 assert_eq!( 2708 empty_draft.publish_blockers(), 2709 vec![ 2710 ProductPublishBlocker::AddProductName, 2711 ProductPublishBlocker::ChooseCategory, 2712 ProductPublishBlocker::ChooseUnit, 2713 ProductPublishBlocker::SetPrice, 2714 ProductPublishBlocker::SetStock, 2715 ProductPublishBlocker::AttachAvailability, 2716 ] 2717 ); 2718 assert_eq!( 2719 ProductPublishBlocker::AttachAvailability.storage_key(), 2720 "attach_availability" 2721 ); 2722 assert_eq!(empty_draft.price_currency, "USD"); 2723 assert!(!empty_draft.is_publish_ready()); 2724 assert!(ready_draft.is_publish_ready()); 2725 assert!(ready_draft.publish_blockers().is_empty()); 2726 } 2727 2728 #[test] 2729 fn order_status_filter_and_primary_action_storage_keys_are_stable() { 2730 assert_eq!(OrderStatus::NeedsAction.storage_key(), "needs_action"); 2731 assert_eq!(OrderStatus::Scheduled.storage_key(), "scheduled"); 2732 assert_eq!(OrderStatus::Packed.storage_key(), "packed"); 2733 assert_eq!(OrderStatus::Completed.storage_key(), "completed"); 2734 assert_eq!(OrderStatus::Declined.storage_key(), "declined"); 2735 assert_eq!(OrderStatus::NeedsReview.storage_key(), "needs_review"); 2736 assert_eq!(BuyerOrderStatus::Placed.storage_key(), "placed"); 2737 assert_eq!(BuyerOrderStatus::Scheduled.storage_key(), "scheduled"); 2738 assert_eq!(BuyerOrderStatus::Ready.storage_key(), "ready"); 2739 assert_eq!(BuyerOrderStatus::Completed.storage_key(), "completed"); 2740 assert_eq!(BuyerOrderStatus::Declined.storage_key(), "declined"); 2741 assert_eq!(BuyerOrderStatus::NeedsReview.storage_key(), "needs_review"); 2742 assert_eq!( 2743 BuyerOrderStatus::from(OrderStatus::NeedsAction), 2744 BuyerOrderStatus::Placed 2745 ); 2746 assert_eq!( 2747 BuyerOrderStatus::from(OrderStatus::Packed), 2748 BuyerOrderStatus::Ready 2749 ); 2750 assert_eq!( 2751 BuyerOrderStatus::from(OrderStatus::Declined), 2752 BuyerOrderStatus::Declined 2753 ); 2754 assert_eq!( 2755 BuyerOrderStatus::from(OrderStatus::NeedsReview), 2756 BuyerOrderStatus::NeedsReview 2757 ); 2758 2759 assert_eq!(OrdersFilter::default(), OrdersFilter::NeedsAction); 2760 assert_eq!(OrdersFilter::All.storage_key(), "all"); 2761 assert_eq!(OrdersFilter::NeedsAction.storage_key(), "needs_action"); 2762 assert_eq!(OrdersFilter::Scheduled.storage_key(), "scheduled"); 2763 assert_eq!(OrdersFilter::Packed.storage_key(), "packed"); 2764 assert_eq!(OrdersFilter::Completed.storage_key(), "completed"); 2765 2766 assert_eq!(OrderPrimaryAction::Review.storage_key(), "review"); 2767 } 2768 2769 fn test_decimal(raw: &str) -> RadrootsCoreDecimal { 2770 raw.parse().expect("test decimal should parse") 2771 } 2772 2773 fn test_usd(raw: &str) -> RadrootsCoreMoney { 2774 RadrootsCoreMoney::new(test_decimal(raw), RadrootsCoreCurrency::USD) 2775 } 2776 2777 fn test_order_id(raw: &str) -> RadrootsOrderId { 2778 raw.parse().expect("test order id should parse") 2779 } 2780 2781 fn test_quote_id(raw: &str) -> RadrootsOrderQuoteId { 2782 raw.parse().expect("test quote id should parse") 2783 } 2784 2785 fn test_bin_id(raw: &str) -> RadrootsInventoryBinId { 2786 raw.parse().expect("test bin id should parse") 2787 } 2788 2789 fn test_event_id(raw: &str) -> RadrootsEventId { 2790 raw.parse().expect("test event id should parse") 2791 } 2792 2793 fn test_pubkey(raw: &str) -> RadrootsPublicKey { 2794 raw.parse().expect("test pubkey should parse") 2795 } 2796 2797 fn test_listing_addr(raw: &str) -> RadrootsListingAddress { 2798 raw.parse().expect("test listing address should parse") 2799 } 2800 2801 fn test_trade_economics() -> RadrootsOrderEconomics { 2802 RadrootsOrderEconomics { 2803 quote_id: test_quote_id("quote-1"), 2804 quote_version: 2, 2805 pricing_basis: RadrootsOrderPricingBasis::ListingEvent, 2806 currency: RadrootsCoreCurrency::USD, 2807 items: vec![RadrootsOrderEconomicItem { 2808 bin_id: test_bin_id("bin-1"), 2809 bin_count: 2, 2810 quantity_amount: test_decimal("1"), 2811 quantity_unit: RadrootsCoreUnit::Each, 2812 unit_price_amount: test_decimal("6.17"), 2813 unit_price_currency: RadrootsCoreCurrency::USD, 2814 line_subtotal: test_usd("12.34"), 2815 }], 2816 discounts: Vec::new(), 2817 adjustments: Vec::new(), 2818 subtotal: test_usd("12.34"), 2819 discount_total: test_usd("0"), 2820 adjustment_total: test_usd("0"), 2821 total: test_usd("12.34"), 2822 } 2823 } 2824 2825 fn test_active_order_projection(status: RadrootsOrderStatus) -> RadrootsOrderProjection { 2826 RadrootsOrderProjection { 2827 order_id: test_order_id("ord_AAAAAAAAAAAAAAAAAAAAAg"), 2828 status, 2829 request_event_id: Some(test_event_id( 2830 "1111111111111111111111111111111111111111111111111111111111111111", 2831 )), 2832 decision_event_id: Some(test_event_id( 2833 "2222222222222222222222222222222222222222222222222222222222222222", 2834 )), 2835 cancellation_event_id: None, 2836 lifecycle_terminal: false, 2837 economics: Some(test_trade_economics()), 2838 agreement_event_id: Some(test_event_id( 2839 "2222222222222222222222222222222222222222222222222222222222222222", 2840 )), 2841 pending_revision_event_id: None, 2842 listing_addr: Some(test_listing_addr( 2843 "30402:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:AAAAAAAAAAAAAAAAAAAAAg", 2844 )), 2845 buyer_pubkey: Some(test_pubkey( 2846 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2847 )), 2848 seller_pubkey: Some(test_pubkey( 2849 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", 2850 )), 2851 last_event_id: Some(test_event_id( 2852 "3333333333333333333333333333333333333333333333333333333333333333", 2853 )), 2854 issues: Vec::new(), 2855 } 2856 } 2857 2858 #[test] 2859 fn trade_workflow_projection_maps_shared_active_order_projection_to_product_axes() { 2860 assert_eq!( 2861 TradeAgreementStatus::from_active_order_status(&RadrootsOrderStatus::Requested), 2862 TradeAgreementStatus::Ordered 2863 ); 2864 assert_eq!( 2865 TradeAgreementStatus::from_active_order_status(&RadrootsOrderStatus::Accepted), 2866 TradeAgreementStatus::Confirmed 2867 ); 2868 assert_eq!( 2869 TradeAgreementStatus::from_active_order_status(&RadrootsOrderStatus::Invalid), 2870 TradeAgreementStatus::NeedsReview 2871 ); 2872 assert_eq!( 2873 TradeRevisionStatus::try_from_storage_key("none"), 2874 Ok(TradeRevisionStatus::None) 2875 ); 2876 assert_eq!( 2877 TradeRevisionStatus::try_from_storage_key("change_proposed"), 2878 Ok(TradeRevisionStatus::ChangeProposed) 2879 ); 2880 assert_eq!( 2881 TradeRevisionStatus::try_from_storage_key("updated"), 2882 Ok(TradeRevisionStatus::Updated) 2883 ); 2884 assert_eq!( 2885 TradeRevisionStatus::try_from_storage_key("kept_as_placed"), 2886 Ok(TradeRevisionStatus::KeptAsPlaced) 2887 ); 2888 assert_eq!( 2889 TradeRevisionStatus::try_from_storage_key("proposed") 2890 .expect_err("shared reducer key should not parse as app revision key") 2891 .value(), 2892 "proposed" 2893 ); 2894 assert!( 2895 TradeRevisionStatus::try_from_storage_key(" none ").is_err(), 2896 "storage keys must parse exactly" 2897 ); 2898 2899 let order_id = OrderId::new(); 2900 let active_order = test_active_order_projection(RadrootsOrderStatus::Accepted); 2901 let projection = TradeWorkflowProjection::from_active_order_projection( 2902 order_id, 2903 &active_order, 2904 TradeRevisionStatus::Updated, 2905 TradeProvenanceProjection::from_primary_source(TradeWorkflowSource::LocalEvents), 2906 ); 2907 assert_eq!(projection.order_id, order_id); 2908 assert_eq!(projection.agreement, TradeAgreementStatus::Confirmed); 2909 assert_eq!(projection.revision, TradeRevisionStatus::Updated); 2910 assert_eq!(projection.inventory, TradeInventoryStatus::Reserved); 2911 assert_eq!(projection.economics.total_minor_units, Some(1234)); 2912 assert_eq!(projection.economics.currency_code.as_deref(), Some("USD")); 2913 assert_eq!( 2914 projection.provenance, 2915 TradeProvenanceProjection::from_primary_source(TradeWorkflowSource::LocalEvents) 2916 .with_last_event_id(Some( 2917 "3333333333333333333333333333333333333333333333333333333333333333".to_owned() 2918 )) 2919 ); 2920 assert_eq!( 2921 order_status_from_active_order_projection(&active_order), 2922 Some(OrderStatus::Scheduled) 2923 ); 2924 2925 let requested_order = test_active_order_projection(RadrootsOrderStatus::Requested); 2926 let requested_projection = TradeWorkflowProjection::from_active_order_projection( 2927 order_id, 2928 &requested_order, 2929 TradeRevisionStatus::None, 2930 TradeProvenanceProjection::from_primary_source(TradeWorkflowSource::LocalEvents), 2931 ); 2932 assert_eq!( 2933 requested_projection.agreement, 2934 TradeAgreementStatus::Ordered 2935 ); 2936 assert_eq!( 2937 requested_projection.inventory, 2938 TradeInventoryStatus::NeedsReview 2939 ); 2940 } 2941 2942 #[test] 2943 fn validation_receipt_projection_maps_shared_receipt_metadata_passively() { 2944 assert_eq!( 2945 TradeValidationReceiptResult::from_validation_receipt_result( 2946 RadrootsValidationReceiptResult::Valid 2947 ), 2948 TradeValidationReceiptResult::Valid 2949 ); 2950 assert_eq!( 2951 TradeValidationReceiptResult::from_validation_receipt_result( 2952 RadrootsValidationReceiptResult::Invalid 2953 ), 2954 TradeValidationReceiptResult::NeedsReview 2955 ); 2956 assert_eq!( 2957 TradeValidationReceiptType::from_validation_receipt_type( 2958 RadrootsValidationReceiptType::TradeTransition 2959 ), 2960 TradeValidationReceiptType::TradeTransition 2961 ); 2962 assert_eq!( 2963 TradeValidationReceiptProofSystem::from_validation_receipt_proof_system( 2964 RadrootsValidationReceiptProofSystem::Sp1Compressed 2965 ), 2966 TradeValidationReceiptProofSystem::Sp1Compressed 2967 ); 2968 assert_eq!(TradeValidationReceiptResult::Valid.storage_key(), "valid"); 2969 assert_eq!( 2970 TradeValidationReceiptResult::NeedsReview.storage_key(), 2971 "needs_review" 2972 ); 2973 assert_eq!( 2974 TradeValidationReceiptType::TradeTransition.storage_key(), 2975 "trade_transition" 2976 ); 2977 assert_eq!( 2978 TradeValidationReceiptProofSystem::Sp1Compressed.storage_key(), 2979 "sp1_compressed" 2980 ); 2981 assert_eq!( 2982 TradeValidationReceiptResult::NeedsReview.label_key_id(), 2983 "messages.trade.validation.result.needs_review" 2984 ); 2985 assert_eq!( 2986 TradeValidationReceiptType::InventoryState.label_key_id(), 2987 "messages.trade.validation.type.inventory_state" 2988 ); 2989 assert_eq!( 2990 TradeValidationReceiptProofSystem::Sp1Compressed.label_key_id(), 2991 "messages.trade.validation.proof.sp1_compressed" 2992 ); 2993 } 2994 2995 #[test] 2996 fn trade_workflow_projection_uses_localization_key_ids_for_visible_status_labels() { 2997 assert_eq!( 2998 TradeAgreementStatus::from_active_order_status(&RadrootsOrderStatus::Requested) 2999 .storage_key(), 3000 "ordered" 3001 ); 3002 assert_eq!(TradeAgreementStatus::Ordered.storage_key(), "ordered"); 3003 assert_eq!( 3004 TradeRevisionStatus::KeptAsPlaced.storage_key(), 3005 "kept_as_placed" 3006 ); 3007 assert_eq!(TradeInventoryStatus::Reserved.storage_key(), "reserved"); 3008 assert_eq!( 3009 TradeWorkflowSource::LocalEvents.storage_key(), 3010 "local_events" 3011 ); 3012 3013 assert_eq!( 3014 TradeAgreementStatus::Ordered.label_key_id(), 3015 "messages.trade.workflow.agreement.ordered" 3016 ); 3017 assert_eq!( 3018 TradeAgreementStatus::NeedsReview.label_key_id(), 3019 "messages.trade.workflow.agreement.needs_review" 3020 ); 3021 assert_eq!( 3022 TradeRevisionStatus::ChangeProposed.label_key_id(), 3023 "messages.trade.workflow.revision.change_proposed" 3024 ); 3025 assert_eq!( 3026 TradeInventoryStatus::SoldOut.label_key_id(), 3027 "messages.trade.workflow.inventory.sold_out" 3028 ); 3029 assert_eq!( 3030 TradeWorkflowSource::Cli.label_key_id(), 3031 "messages.trade.workflow.provenance.cli" 3032 ); 3033 } 3034 3035 #[test] 3036 fn orders_and_pack_day_query_state_defaults_are_frozen() { 3037 assert_eq!( 3038 OrdersScreenQueryState::default(), 3039 OrdersScreenQueryState { 3040 filter: OrdersFilter::NeedsAction, 3041 fulfillment_window_id: None, 3042 } 3043 ); 3044 assert_eq!( 3045 PackDayScreenQueryState::default(), 3046 PackDayScreenQueryState { 3047 fulfillment_window_id: None, 3048 } 3049 ); 3050 } 3051 3052 #[test] 3053 fn pack_day_export_print_and_host_handoff_contracts_are_frozen_for_v1() { 3054 assert_eq!( 3055 PackDayExportArtifactKind::all_v1(), 3056 [ 3057 PackDayExportArtifactKind::PackSheet, 3058 PackDayExportArtifactKind::PickupRoster, 3059 PackDayExportArtifactKind::CustomerLabels, 3060 ] 3061 ); 3062 assert_eq!( 3063 PackDayExportArtifactKind::PackSheet.storage_key(), 3064 "pack_sheet" 3065 ); 3066 assert_eq!( 3067 PackDayExportArtifactKind::PackSheet.file_name(), 3068 "pack_sheet.txt" 3069 ); 3070 assert_eq!( 3071 PackDayExportArtifactKind::PickupRoster.file_name(), 3072 "pickup_roster.txt" 3073 ); 3074 assert_eq!( 3075 PackDayExportArtifactKind::CustomerLabels.file_name(), 3076 "customer_labels.txt" 3077 ); 3078 assert_eq!(PackDayExportStatus::default(), PackDayExportStatus::Idle); 3079 assert_eq!(PackDayExportStatus::Running.storage_key(), "running"); 3080 assert_eq!(PackDayExportStatus::Succeeded.storage_key(), "succeeded"); 3081 assert_eq!(PackDayExportStatus::Failed.storage_key(), "failed"); 3082 assert_eq!( 3083 PackDayPrintKind::all_v1(), 3084 [ 3085 PackDayPrintKind::PrintPackSheet, 3086 PackDayPrintKind::PrintPickupRoster, 3087 PackDayPrintKind::PrintCustomerLabels, 3088 ] 3089 ); 3090 assert_eq!( 3091 PackDayPrintKind::PrintPackSheet.storage_key(), 3092 "print_pack_sheet" 3093 ); 3094 assert_eq!( 3095 PackDayPrintKind::PrintPickupRoster.storage_key(), 3096 "print_pickup_roster" 3097 ); 3098 assert_eq!( 3099 PackDayPrintKind::PrintCustomerLabels.storage_key(), 3100 "print_customer_labels" 3101 ); 3102 assert_eq!( 3103 PackDayPrintKind::PrintPackSheet.artifact_kind(), 3104 PackDayExportArtifactKind::PackSheet 3105 ); 3106 assert_eq!( 3107 PackDayPrintKind::PrintPickupRoster.artifact_kind(), 3108 PackDayExportArtifactKind::PickupRoster 3109 ); 3110 assert_eq!( 3111 PackDayPrintKind::PrintCustomerLabels.artifact_kind(), 3112 PackDayExportArtifactKind::CustomerLabels 3113 ); 3114 assert_eq!(PackDayPrintKind::PrintPackSheet.label_stock(), None); 3115 assert_eq!(PackDayPrintKind::PrintPickupRoster.label_stock(), None); 3116 assert_eq!( 3117 PackDayPrintKind::PrintCustomerLabels.label_stock(), 3118 Some(PackDayPrintLabelStock::Avery5160Letter30Up) 3119 ); 3120 assert_eq!( 3121 PackDayPrintLabelStock::all_v1(), 3122 [PackDayPrintLabelStock::Avery5160Letter30Up] 3123 ); 3124 assert_eq!( 3125 PackDayPrintLabelStock::Avery5160Letter30Up.storage_key(), 3126 "avery_5160_letter_30_up" 3127 ); 3128 assert_eq!( 3129 PackDayPrintFailureKind::CustomerLabelsAvery5160Overflow.storage_key(), 3130 "customer_labels_avery_5160_overflow" 3131 ); 3132 assert_eq!( 3133 PackDayBatchPrintArtifact::all_v1(), 3134 [ 3135 PackDayBatchPrintArtifact { 3136 print_kind: PackDayPrintKind::PrintPackSheet, 3137 artifact_kind: PackDayExportArtifactKind::PackSheet, 3138 label_stock: None, 3139 }, 3140 PackDayBatchPrintArtifact { 3141 print_kind: PackDayPrintKind::PrintPickupRoster, 3142 artifact_kind: PackDayExportArtifactKind::PickupRoster, 3143 label_stock: None, 3144 }, 3145 PackDayBatchPrintArtifact { 3146 print_kind: PackDayPrintKind::PrintCustomerLabels, 3147 artifact_kind: PackDayExportArtifactKind::CustomerLabels, 3148 label_stock: Some(PackDayPrintLabelStock::Avery5160Letter30Up), 3149 }, 3150 ] 3151 ); 3152 assert_eq!( 3153 PackDayBatchPrintArtifact::from_print_kind(PackDayPrintKind::PrintCustomerLabels), 3154 PackDayBatchPrintArtifact { 3155 print_kind: PackDayPrintKind::PrintCustomerLabels, 3156 artifact_kind: PackDayExportArtifactKind::CustomerLabels, 3157 label_stock: Some(PackDayPrintLabelStock::Avery5160Letter30Up), 3158 } 3159 ); 3160 assert_eq!( 3161 PackDayBatchPrintFailureKind::Preflight.storage_key(), 3162 "preflight" 3163 ); 3164 assert_eq!( 3165 PackDayBatchPrintFailureKind::QueueLaunch.storage_key(), 3166 "queue_launch" 3167 ); 3168 assert_eq!( 3169 PackDayBatchPrintFailureKind::QueueExit.storage_key(), 3170 "queue_exit" 3171 ); 3172 assert_eq!( 3173 PackDayBatchPrintFailureKind::CustomerLabelsAvery5160Overflow.storage_key(), 3174 "customer_labels_avery_5160_overflow" 3175 ); 3176 assert_eq!( 3177 PackDayBatchPrintStatus::default(), 3178 PackDayBatchPrintStatus::Idle 3179 ); 3180 assert_eq!(PackDayBatchPrintStatus::Running.storage_key(), "running"); 3181 assert_eq!( 3182 PackDayBatchPrintStatus::Succeeded.storage_key(), 3183 "succeeded" 3184 ); 3185 assert_eq!(PackDayBatchPrintStatus::Failed.storage_key(), "failed"); 3186 assert_eq!(PackDayPrintStatus::default(), PackDayPrintStatus::Idle); 3187 assert_eq!(PackDayPrintStatus::Running.storage_key(), "running"); 3188 assert_eq!(PackDayPrintStatus::Succeeded.storage_key(), "succeeded"); 3189 assert_eq!(PackDayPrintStatus::Failed.storage_key(), "failed"); 3190 assert_eq!( 3191 PackDayHostHandoffKind::all_v1(), 3192 [ 3193 PackDayHostHandoffKind::RevealBundle, 3194 PackDayHostHandoffKind::OpenPackSheet, 3195 PackDayHostHandoffKind::OpenPickupRoster, 3196 PackDayHostHandoffKind::OpenCustomerLabels, 3197 ] 3198 ); 3199 assert_eq!( 3200 PackDayHostHandoffKind::RevealBundle.storage_key(), 3201 "reveal_bundle" 3202 ); 3203 assert_eq!( 3204 PackDayHostHandoffKind::OpenPackSheet.storage_key(), 3205 "open_pack_sheet" 3206 ); 3207 assert_eq!( 3208 PackDayHostHandoffKind::OpenPickupRoster.storage_key(), 3209 "open_pickup_roster" 3210 ); 3211 assert_eq!( 3212 PackDayHostHandoffKind::OpenCustomerLabels.storage_key(), 3213 "open_customer_labels" 3214 ); 3215 assert_eq!(PackDayHostHandoffKind::RevealBundle.artifact_kind(), None); 3216 assert_eq!( 3217 PackDayHostHandoffKind::OpenPackSheet.artifact_kind(), 3218 Some(PackDayExportArtifactKind::PackSheet) 3219 ); 3220 assert_eq!( 3221 PackDayHostHandoffKind::OpenPickupRoster.artifact_kind(), 3222 Some(PackDayExportArtifactKind::PickupRoster) 3223 ); 3224 assert_eq!( 3225 PackDayHostHandoffKind::OpenCustomerLabels.artifact_kind(), 3226 Some(PackDayExportArtifactKind::CustomerLabels) 3227 ); 3228 assert_eq!( 3229 PackDayHostHandoffStatus::default(), 3230 PackDayHostHandoffStatus::Idle 3231 ); 3232 assert_eq!(PackDayHostHandoffStatus::Running.storage_key(), "running"); 3233 assert_eq!( 3234 PackDayHostHandoffStatus::Succeeded.storage_key(), 3235 "succeeded" 3236 ); 3237 assert_eq!(PackDayHostHandoffStatus::Failed.storage_key(), "failed"); 3238 } 3239 3240 #[test] 3241 fn pack_day_output_order_state_freezes_the_v1_status_subset() { 3242 assert_eq!( 3243 PackDayOutputOrderState::all_v1(), 3244 [ 3245 PackDayOutputOrderState::NeedsAction, 3246 PackDayOutputOrderState::Scheduled, 3247 PackDayOutputOrderState::Packed, 3248 ] 3249 ); 3250 assert_eq!( 3251 PackDayOutputOrderState::from_order_status(OrderStatus::NeedsAction), 3252 Some(PackDayOutputOrderState::NeedsAction) 3253 ); 3254 assert_eq!( 3255 PackDayOutputOrderState::from_order_status(OrderStatus::Scheduled), 3256 Some(PackDayOutputOrderState::Scheduled) 3257 ); 3258 assert_eq!( 3259 PackDayOutputOrderState::from_order_status(OrderStatus::Packed), 3260 Some(PackDayOutputOrderState::Packed) 3261 ); 3262 assert_eq!( 3263 PackDayOutputOrderState::from_order_status(OrderStatus::Completed), 3264 None 3265 ); 3266 assert_eq!( 3267 PackDayOutputOrderState::from_order_status(OrderStatus::Declined), 3268 None 3269 ); 3270 assert_eq!( 3271 PackDayOutputOrderState::from_order_status(OrderStatus::NeedsReview), 3272 None 3273 ); 3274 assert_eq!( 3275 OrderStatus::from(PackDayOutputOrderState::Packed), 3276 OrderStatus::Packed 3277 ); 3278 } 3279 3280 #[test] 3281 fn pack_day_output_source_keeps_export_truth_out_of_ui_display_strings() { 3282 let farm_id = FarmId::new(); 3283 let fulfillment_window_id = FulfillmentWindowId::new(); 3284 let order_id = OrderId::new(); 3285 let screen_row = PackDayPackListRow { 3286 title: "Salad mix".to_owned(), 3287 quantity_display: "Casey: 2 bags".to_owned(), 3288 }; 3289 let source = PackDayOutputSource { 3290 fulfillment_window: PackDayOutputWindow { 3291 fulfillment_window_id, 3292 farm_id, 3293 farm_display_name: "Willow farm".to_owned(), 3294 pickup_location_label: Some("North barn".to_owned()), 3295 starts_at: "2026-04-23T16:00:00Z".to_owned(), 3296 ends_at: "2026-04-23T19:00:00Z".to_owned(), 3297 }, 3298 totals_by_product: vec![PackDayOutputProductTotal { 3299 title: "Salad mix".to_owned(), 3300 quantity: PackDayOutputQuantity::new(2, "bags"), 3301 }], 3302 pack_list: vec![PackDayOutputPackListEntry { 3303 order_id, 3304 order_number: "R-1001".to_owned(), 3305 customer_display_name: "Casey".to_owned(), 3306 order_state: PackDayOutputOrderState::Scheduled, 3307 title: "Salad mix".to_owned(), 3308 quantity: PackDayOutputQuantity::new(2, "bags"), 3309 }], 3310 pickup_roster: vec![PackDayOutputCustomerOrder { 3311 order_id, 3312 order_number: "R-1001".to_owned(), 3313 customer_display_name: "Casey".to_owned(), 3314 order_state: PackDayOutputOrderState::Scheduled, 3315 }], 3316 }; 3317 3318 assert_eq!(screen_row.quantity_display, "Casey: 2 bags"); 3319 assert!(!source.is_empty()); 3320 assert_eq!(source.pack_list[0].customer_display_name, "Casey"); 3321 assert_eq!(source.pack_list[0].quantity.value, 2); 3322 assert_eq!(source.pack_list[0].quantity.unit_label, "bags"); 3323 assert_eq!( 3324 source.pickup_roster[0].order_state.storage_key(), 3325 "scheduled" 3326 ); 3327 } 3328 3329 #[test] 3330 fn pack_day_export_bundle_tracks_output_directory_and_artifacts() { 3331 let fulfillment_window_id = FulfillmentWindowId::new(); 3332 let bundle = PackDayExportBundle { 3333 fulfillment_window_id, 3334 export_instance_id: PackDayExportInstanceId::new(), 3335 generated_at_utc: "2026-04-23T15:00:00Z".to_owned(), 3336 bundle_directory: "exports/pack_day/window-1/20260423T150000Z".to_owned(), 3337 artifacts: vec![ 3338 PackDayExportArtifact { 3339 kind: PackDayExportArtifactKind::PackSheet, 3340 relative_path: "pack_sheet.txt".to_owned(), 3341 }, 3342 PackDayExportArtifact { 3343 kind: PackDayExportArtifactKind::PickupRoster, 3344 relative_path: "pickup_roster.txt".to_owned(), 3345 }, 3346 ], 3347 }; 3348 3349 assert_eq!(bundle.fulfillment_window_id, fulfillment_window_id); 3350 assert_eq!(bundle.artifact_count(), 2); 3351 assert!(bundle.includes_artifact(PackDayExportArtifactKind::PackSheet)); 3352 assert!(bundle.includes_artifact(PackDayExportArtifactKind::PickupRoster)); 3353 assert!(!bundle.includes_artifact(PackDayExportArtifactKind::CustomerLabels)); 3354 } 3355 3356 #[test] 3357 fn orders_and_pack_day_projections_hold_truthful_execution_data() { 3358 let fulfillment_window_id = super::FulfillmentWindowId::new(); 3359 let farm_id = FarmId::new(); 3360 let order_id = super::OrderId::new(); 3361 let order_economics = TradeEconomicsProjection { 3362 subtotal_minor_units: Some(1300), 3363 total_minor_units: Some(1300), 3364 currency_code: Some("USD".to_owned()), 3365 ..TradeEconomicsProjection::default() 3366 }; 3367 let orders_list = OrdersListProjection { 3368 summary: OrdersListSummary { 3369 total_orders: 3, 3370 needs_action_orders: 1, 3371 scheduled_orders: 1, 3372 packed_orders: 1, 3373 }, 3374 rows: vec![OrdersListRow { 3375 order_id, 3376 farm_id, 3377 fulfillment_window_id: Some(fulfillment_window_id), 3378 order_number: "R-1001".to_owned(), 3379 customer_display_name: "Casey".to_owned(), 3380 fulfillment_window_label: Some("Wednesday pickup".to_owned()), 3381 pickup_location_label: Some("North barn".to_owned()), 3382 status: OrderStatus::Scheduled, 3383 workflow: TradeWorkflowProjection::from_order_status( 3384 order_id, 3385 OrderStatus::Scheduled, 3386 ), 3387 primary_action: None, 3388 }], 3389 }; 3390 let order_detail = OrderDetailProjection { 3391 order_id, 3392 farm_id, 3393 order_number: "R-1001".to_owned(), 3394 customer_display_name: "Casey".to_owned(), 3395 status: OrderStatus::Scheduled, 3396 fulfillment_window_id: Some(fulfillment_window_id), 3397 fulfillment_window_label: Some("Wednesday pickup".to_owned()), 3398 pickup_location_label: Some("North barn".to_owned()), 3399 items: vec![OrderDetailItemRow { 3400 title: "Salad mix".to_owned(), 3401 quantity_display: "2 bags".to_owned(), 3402 unit_price: Some(ProductPricePresentation { 3403 amount_minor_units: 650, 3404 currency_code: "USD".to_owned(), 3405 unit_label: "bag".to_owned(), 3406 }), 3407 line_total_minor_units: Some(1300), 3408 }], 3409 economics: order_economics.clone(), 3410 workflow: TradeWorkflowProjection::from_order_status(order_id, OrderStatus::Scheduled) 3411 .with_economics(order_economics), 3412 validation_receipts: Vec::new(), 3413 primary_action: None, 3414 }; 3415 let pack_day = PackDayProjection { 3416 fulfillment_window: Some(super::FulfillmentWindowSummary { 3417 fulfillment_window_id, 3418 farm_id, 3419 starts_at: "2026-04-23T16:00:00Z".to_owned(), 3420 ends_at: "2026-04-23T19:00:00Z".to_owned(), 3421 }), 3422 totals_by_product: vec![PackDayProductTotalRow { 3423 title: "Salad mix".to_owned(), 3424 quantity_display: "8 bags".to_owned(), 3425 }], 3426 pack_list: vec![PackDayPackListRow { 3427 title: "Salad mix".to_owned(), 3428 quantity_display: "Casey: 2 bags".to_owned(), 3429 }], 3430 pickup_roster: vec![PackDayRosterRow { 3431 order_id, 3432 order_number: "R-1001".to_owned(), 3433 customer_display_name: "Casey".to_owned(), 3434 }], 3435 reminders: ReminderFeedProjection::default(), 3436 }; 3437 3438 assert!(orders_list.summary.has_orders()); 3439 assert!(!orders_list.is_empty()); 3440 assert_eq!(orders_list.rows[0].primary_action, None); 3441 assert_eq!( 3442 orders_list.rows[0].workflow.agreement, 3443 TradeAgreementStatus::Confirmed 3444 ); 3445 assert_eq!(order_detail.items[0].quantity_display, "2 bags"); 3446 assert_eq!( 3447 order_detail.workflow.inventory, 3448 TradeInventoryStatus::Reserved 3449 ); 3450 assert!(!pack_day.is_empty()); 3451 assert_eq!(pack_day.pickup_roster[0].order_number, "R-1001"); 3452 } 3453 3454 #[test] 3455 fn buyer_marketplace_projections_hold_guest_capable_contract_data() { 3456 let farm_id = FarmId::new(); 3457 let product_id = super::ProductId::new(); 3458 let order_id = super::OrderId::new(); 3459 let buyer_order_economics = TradeEconomicsProjection { 3460 subtotal_minor_units: Some(1300), 3461 total_minor_units: Some(1300), 3462 currency_code: Some("USD".to_owned()), 3463 ..TradeEconomicsProjection::default() 3464 }; 3465 let listing = BuyerListingRow { 3466 product_id, 3467 farm_id, 3468 farm_display_name: "Cedar Grove Farm".to_owned(), 3469 listing_relays: vec!["wss://relay.example".to_owned()], 3470 title: "Spring salad mix".to_owned(), 3471 subtitle: Some("Tender leaves".to_owned()), 3472 price: ProductPricePresentation { 3473 amount_minor_units: 650, 3474 currency_code: "USD".to_owned(), 3475 unit_label: "bag".to_owned(), 3476 }, 3477 availability: ProductAvailabilitySummary { 3478 state: ProductAvailabilityState::Scheduled, 3479 label: "Thursday pickup".to_owned(), 3480 }, 3481 stock: ProductStockSummary { 3482 quantity: Some(8), 3483 unit_label: Some("bag".to_owned()), 3484 state: ProductStockState::InStock, 3485 }, 3486 fulfillment_methods: BTreeSet::from([FarmOrderMethod::Pickup]), 3487 next_fulfillment_window_label: Some("Thursday pickup".to_owned()), 3488 }; 3489 let listings = BuyerListingsProjection { 3490 rows: vec![listing.clone()], 3491 }; 3492 let cart = BuyerCartProjection { 3493 farm_id: Some(farm_id), 3494 farm_display_name: Some("Cedar Grove Farm".to_owned()), 3495 lines: vec![BuyerCartLineProjection { 3496 product_id, 3497 farm_id, 3498 farm_display_name: "Cedar Grove Farm".to_owned(), 3499 title: "Spring salad mix".to_owned(), 3500 quantity: 2, 3501 unit_price: ProductPricePresentation { 3502 amount_minor_units: 650, 3503 currency_code: "USD".to_owned(), 3504 unit_label: "bag".to_owned(), 3505 }, 3506 line_total_minor_units: 1300, 3507 fulfillment_summary: "Thursday pickup".to_owned(), 3508 }], 3509 subtotal_minor_units: Some(1300), 3510 currency_code: Some("USD".to_owned()), 3511 replace_confirmation: None, 3512 }; 3513 let order_review = BuyerOrderReviewProjection { 3514 draft: BuyerOrderReviewDraft { 3515 name: "Casey Buyer".to_owned(), 3516 email: "casey@example.com".to_owned(), 3517 phone: String::new(), 3518 order_note: "Leave by the cooler".to_owned(), 3519 }, 3520 summary: BuyerOrderReviewSummaryProjection { 3521 farm_display_name: Some("Cedar Grove Farm".to_owned()), 3522 fulfillment_summary: Some("Thursday pickup".to_owned()), 3523 line_count: 1, 3524 subtotal_minor_units: Some(1300), 3525 currency_code: Some("USD".to_owned()), 3526 }, 3527 can_place_order: true, 3528 place_order_disabled_reason: None, 3529 }; 3530 let orders = BuyerOrdersProjection { 3531 rows: vec![BuyerOrdersListRow { 3532 order_id, 3533 farm_id, 3534 order_number: "R-2001".to_owned(), 3535 farm_display_name: "Cedar Grove Farm".to_owned(), 3536 fulfillment_summary: "Thursday pickup".to_owned(), 3537 status: BuyerOrderStatus::Scheduled, 3538 workflow: TradeWorkflowProjection::from_buyer_order_status( 3539 order_id, 3540 BuyerOrderStatus::Scheduled, 3541 ), 3542 repeat_demand: None, 3543 }], 3544 }; 3545 let order_detail = BuyerOrderDetailProjection { 3546 order_id, 3547 farm_id, 3548 order_number: "R-2001".to_owned(), 3549 farm_display_name: "Cedar Grove Farm".to_owned(), 3550 fulfillment_summary: "Thursday pickup".to_owned(), 3551 status: BuyerOrderStatus::Scheduled, 3552 items: vec![OrderDetailItemRow { 3553 title: "Spring salad mix".to_owned(), 3554 quantity_display: "2 bags".to_owned(), 3555 unit_price: Some(ProductPricePresentation { 3556 amount_minor_units: 650, 3557 currency_code: "USD".to_owned(), 3558 unit_label: "bag".to_owned(), 3559 }), 3560 line_total_minor_units: Some(1300), 3561 }], 3562 economics: buyer_order_economics.clone(), 3563 workflow: TradeWorkflowProjection::from_buyer_order_status( 3564 order_id, 3565 BuyerOrderStatus::Scheduled, 3566 ) 3567 .with_economics(buyer_order_economics), 3568 validation_receipts: Vec::new(), 3569 order_note: Some("Leave by the cooler".to_owned()), 3570 repeat_demand: None, 3571 }; 3572 3573 assert!(!listings.is_empty()); 3574 assert!(!cart.is_empty()); 3575 assert!(order_review.can_place_order); 3576 assert!(!orders.is_empty()); 3577 assert_eq!(listing.fulfillment_methods.len(), 1); 3578 assert_eq!(order_detail.status, BuyerOrderStatus::Scheduled); 3579 assert_eq!( 3580 order_detail.workflow.agreement, 3581 TradeAgreementStatus::Confirmed 3582 ); 3583 } 3584 3585 #[test] 3586 fn today_agenda_stays_on_the_compact_order_row_contract() { 3587 let today = TodayAgendaProjection { 3588 orders_needing_action: vec![OrderListRow { 3589 order_id: super::OrderId::new(), 3590 farm_id: FarmId::new(), 3591 fulfillment_window_id: Some(super::FulfillmentWindowId::new()), 3592 order_number: "R-1002".to_owned(), 3593 customer_display_name: "Morgan".to_owned(), 3594 status: OrderStatus::NeedsAction, 3595 }], 3596 ..TodayAgendaProjection::default() 3597 }; 3598 let orders_row_id = super::OrderId::new(); 3599 let orders_row = OrdersListRow { 3600 order_id: orders_row_id, 3601 farm_id: FarmId::new(), 3602 fulfillment_window_id: None, 3603 order_number: "R-2002".to_owned(), 3604 customer_display_name: "Robin".to_owned(), 3605 fulfillment_window_label: None, 3606 pickup_location_label: None, 3607 status: OrderStatus::Completed, 3608 workflow: TradeWorkflowProjection::from_order_status( 3609 orders_row_id, 3610 OrderStatus::Completed, 3611 ), 3612 primary_action: None, 3613 }; 3614 3615 assert_eq!(today.orders_needing_action.len(), 1); 3616 assert_eq!( 3617 today.orders_needing_action[0].status, 3618 OrderStatus::NeedsAction 3619 ); 3620 assert_eq!(orders_row.primary_action, None); 3621 assert_eq!(orders_row.status, OrderStatus::Completed); 3622 } 3623 3624 #[test] 3625 fn today_summary_attention_state_is_explicit() { 3626 let quiet = TodaySummary { 3627 farm_id: FarmId::new(), 3628 orders_needing_action: 0, 3629 low_stock_products: 0, 3630 draft_products: 0, 3631 reminders_due_soon: 0, 3632 }; 3633 let busy = TodaySummary { 3634 farm_id: FarmId::new(), 3635 orders_needing_action: 1, 3636 low_stock_products: 0, 3637 draft_products: 0, 3638 reminders_due_soon: 0, 3639 }; 3640 3641 assert!(!quiet.has_attention_items()); 3642 assert!(busy.has_attention_items()); 3643 } 3644 3645 #[test] 3646 fn reminder_and_repeat_demand_contracts_are_explicit() { 3647 let farm_id = FarmId::new(); 3648 let order_id = OrderId::new(); 3649 let fulfillment_window_id = FulfillmentWindowId::new(); 3650 let reminder = ReminderDeadlineProjection { 3651 reminder_id: ReminderId::new(), 3652 farm_id, 3653 order_id: Some(order_id), 3654 fulfillment_window_id: Some(fulfillment_window_id), 3655 kind: ReminderKind::FulfillmentWindow, 3656 surface: ReminderSurface::Today, 3657 urgency: ReminderUrgency::DueSoon, 3658 title: "Pickup closes soon".to_owned(), 3659 detail: "Pack before the pickup window opens.".to_owned(), 3660 deadline_at: "2026-04-24T15:00:00Z".to_owned(), 3661 action_label: Some("Open pack day".to_owned()), 3662 delivery_state: ReminderDeliveryState::Scheduled, 3663 }; 3664 let repeat_demand = RepeatDemandHandoffProjection { 3665 order_id, 3666 farm_id, 3667 eligibility: RepeatDemandEligibility::Partial, 3668 available_item_count: 2, 3669 unavailable_item_count: 1, 3670 }; 3671 3672 let reminder_feed = ReminderFeedProjection { 3673 items: vec![reminder.clone()], 3674 }; 3675 let reminder_log = ReminderLogProjection { 3676 entries: vec![ReminderLogEntryProjection { 3677 reminder_id: reminder.reminder_id, 3678 kind: reminder.kind, 3679 title: reminder.title.clone(), 3680 recorded_at: "2026-04-24T14:00:00Z".to_owned(), 3681 delivery_state: ReminderDeliveryState::Presented, 3682 detail: Some(reminder.detail.clone()), 3683 }], 3684 }; 3685 3686 assert_eq!(ReminderSurface::PackDay.storage_key(), "pack_day"); 3687 assert_eq!(ReminderUrgency::DueSoon.storage_key(), "due_soon"); 3688 assert_eq!( 3689 ReminderDeliveryState::Acknowledged.storage_key(), 3690 "acknowledged" 3691 ); 3692 assert_eq!( 3693 RepeatDemandEligibility::Unavailable.storage_key(), 3694 "unavailable" 3695 ); 3696 assert_eq!(reminder_feed.due_soon_count(), 1); 3697 assert!(!reminder_log.is_empty()); 3698 assert_eq!(repeat_demand.unavailable_item_count, 1); 3699 } 3700 3701 #[test] 3702 fn today_agenda_projection_tracks_attention_and_setup_independently() { 3703 let calm = TodayAgendaProjection::default(); 3704 let with_attention = TodayAgendaProjection { 3705 draft_products: vec![ProductListRow { 3706 product_id: super::ProductId::new(), 3707 farm_id: FarmId::new(), 3708 title: "Spring onions".to_owned(), 3709 status: super::ProductStatus::Draft, 3710 stock_count: 0, 3711 }], 3712 ..TodayAgendaProjection::default() 3713 }; 3714 let with_setup = TodayAgendaProjection { 3715 setup_checklist: vec![TodaySetupTask { 3716 kind: TodaySetupTaskKind::AddFulfillmentWindow, 3717 is_complete: false, 3718 }], 3719 ..TodayAgendaProjection::default() 3720 }; 3721 3722 assert!(!calm.has_attention_items()); 3723 assert!(!calm.needs_setup()); 3724 assert!(with_attention.has_attention_items()); 3725 assert!(!with_attention.needs_setup()); 3726 assert!(!with_setup.has_attention_items()); 3727 assert!(with_setup.needs_setup()); 3728 } 3729 3730 #[test] 3731 fn today_agenda_projection_can_hold_truthful_lists() { 3732 let projection = TodayAgendaProjection { 3733 orders_needing_action: vec![OrderListRow { 3734 order_id: super::OrderId::new(), 3735 farm_id: FarmId::new(), 3736 fulfillment_window_id: Some(super::FulfillmentWindowId::new()), 3737 order_number: "R-1001".to_owned(), 3738 customer_display_name: "Casey".to_owned(), 3739 status: super::OrderStatus::NeedsAction, 3740 }], 3741 low_stock_products: vec![ProductListRow { 3742 product_id: super::ProductId::new(), 3743 farm_id: FarmId::new(), 3744 title: "Carrots".to_owned(), 3745 status: super::ProductStatus::Published, 3746 stock_count: 2, 3747 }], 3748 ..TodayAgendaProjection::default() 3749 }; 3750 3751 assert_eq!(projection.orders_needing_action.len(), 1); 3752 assert_eq!(projection.low_stock_products[0].stock_count, 2); 3753 assert!(projection.has_attention_items()); 3754 } 3755 3756 #[test] 3757 fn farm_setup_section_order_is_frozen() { 3758 assert_eq!( 3759 FarmSetupSection::ordered(), 3760 [ 3761 FarmSetupSection::Farm, 3762 FarmSetupSection::Location, 3763 FarmSetupSection::OrderMethods, 3764 ] 3765 ); 3766 } 3767 3768 #[test] 3769 fn empty_farm_setup_draft_is_not_started_with_all_blockers() { 3770 let draft = FarmSetupDraft::default(); 3771 3772 assert!(draft.is_empty()); 3773 assert_eq!(draft.readiness(), FarmSetupReadiness::NotStarted); 3774 assert_eq!( 3775 draft.blockers(), 3776 vec![ 3777 FarmSetupBlocker::AddFarmName, 3778 FarmSetupBlocker::AddLocationOrServiceArea, 3779 FarmSetupBlocker::ChooseOrderMethod, 3780 ] 3781 ); 3782 } 3783 3784 #[test] 3785 fn partial_farm_setup_draft_is_in_progress() { 3786 let draft = FarmSetupDraft::new("North field farm", "", [FarmOrderMethod::Pickup]); 3787 3788 assert_eq!(draft.readiness(), FarmSetupReadiness::InProgress); 3789 assert_eq!( 3790 draft.blockers(), 3791 vec![FarmSetupBlocker::AddLocationOrServiceArea] 3792 ); 3793 } 3794 3795 #[test] 3796 fn complete_farm_setup_draft_is_ready_and_deduplicates_order_methods() { 3797 let draft = FarmSetupDraft::new( 3798 "North field farm", 3799 "Asheville, NC", 3800 [ 3801 FarmOrderMethod::Shipping, 3802 FarmOrderMethod::Pickup, 3803 FarmOrderMethod::Shipping, 3804 ], 3805 ); 3806 3807 assert_eq!(draft.readiness(), FarmSetupReadiness::Ready); 3808 assert_eq!(draft.blockers(), Vec::<FarmSetupBlocker>::new()); 3809 assert_eq!( 3810 draft.order_methods, 3811 BTreeSet::from([FarmOrderMethod::Pickup, FarmOrderMethod::Shipping]) 3812 ); 3813 } 3814 3815 #[test] 3816 fn saved_farm_projection_is_always_ready() { 3817 let saved_farm = super::FarmSummary { 3818 farm_id: FarmId::new(), 3819 display_name: "North field farm".to_owned(), 3820 readiness: super::FarmReadiness::Ready, 3821 }; 3822 let projection = FarmSetupProjection::from_saved_farm(saved_farm.clone()); 3823 3824 assert_eq!(projection.saved_farm, Some(saved_farm)); 3825 assert_eq!(projection.readiness, FarmSetupReadiness::Ready); 3826 assert!(projection.blockers.is_empty()); 3827 assert!(projection.has_saved_farm()); 3828 } 3829 3830 #[test] 3831 fn farm_rules_projection_defaults_to_missing_v1_requirements() { 3832 let projection = FarmRulesProjection::default(); 3833 3834 assert!(projection.farm_profile.is_none()); 3835 assert!(projection.pickup_locations.is_empty()); 3836 assert!(projection.operating_rules.is_none()); 3837 assert!(projection.fulfillment_windows.is_empty()); 3838 assert!(projection.blackout_periods.is_empty()); 3839 assert_eq!( 3840 projection.readiness, 3841 FarmRulesReadiness::missing_v1_basics() 3842 ); 3843 assert!(!projection.is_ready()); 3844 } 3845 3846 #[test] 3847 fn farm_rules_readiness_and_timing_conflicts_are_explicit() { 3848 let readiness = FarmRulesReadiness { 3849 blockers: vec![FarmReadinessBlocker::MissingOperatingRules], 3850 timing_conflicts: vec![FarmTimingConflict { 3851 kind: FarmTimingConflictKind::BlackoutOverlapsFulfillmentWindow, 3852 fulfillment_window_id: Some(super::FulfillmentWindowId::new()), 3853 blackout_period_id: Some(BlackoutPeriodId::new()), 3854 }], 3855 }; 3856 3857 assert_eq!( 3858 FarmReadinessBlocker::MissingProfileBasics.storage_key(), 3859 "missing_profile_basics" 3860 ); 3861 assert_eq!( 3862 FarmReadinessBlocker::MissingPickupLocation.storage_key(), 3863 "missing_pickup_location" 3864 ); 3865 assert_eq!( 3866 FarmReadinessBlocker::MissingFulfillmentWindow.storage_key(), 3867 "missing_fulfillment_window" 3868 ); 3869 assert_eq!( 3870 FarmReadinessBlocker::MissingOperatingRules.storage_key(), 3871 "missing_operating_rules" 3872 ); 3873 assert_eq!( 3874 FarmTimingConflictKind::FulfillmentWindowEndsBeforeStart.storage_key(), 3875 "fulfillment_window_ends_before_start" 3876 ); 3877 assert_eq!( 3878 FarmTimingConflictKind::FulfillmentWindowCutoffAfterStart.storage_key(), 3879 "fulfillment_window_cutoff_after_start" 3880 ); 3881 assert_eq!( 3882 FarmTimingConflictKind::BlackoutPeriodEndsBeforeStart.storage_key(), 3883 "blackout_period_ends_before_start" 3884 ); 3885 assert_eq!( 3886 FarmTimingConflictKind::BlackoutOverlapsFulfillmentWindow.storage_key(), 3887 "blackout_overlaps_fulfillment_window" 3888 ); 3889 assert!(!readiness.is_ready()); 3890 assert!(FarmRulesReadiness::ready().is_ready()); 3891 } 3892 3893 #[test] 3894 fn farm_rules_projection_represents_full_v1_inventory() { 3895 let farm_id = FarmId::new(); 3896 let pickup_location_id = PickupLocationId::new(); 3897 let fulfillment_window_id = super::FulfillmentWindowId::new(); 3898 let blackout_period_id = BlackoutPeriodId::new(); 3899 let projection = super::FarmRulesProjection { 3900 farm_profile: Some(super::FarmProfileRecord { 3901 farm_id, 3902 display_name: "North field farm".to_owned(), 3903 timezone: "UTC".to_owned(), 3904 currency_code: "USD".to_owned(), 3905 }), 3906 pickup_locations: vec![super::PickupLocationRecord { 3907 pickup_location_id, 3908 farm_id, 3909 label: "Barn pickup".to_owned(), 3910 address_line: "14 Orchard Lane".to_owned(), 3911 directions: Some("Drive to the red barn.".to_owned()), 3912 is_default: true, 3913 }], 3914 operating_rules: Some(super::FarmOperatingRulesRecord { 3915 farm_id, 3916 promise_lead_hours: 24, 3917 substitution_policy: "ask_customer".to_owned(), 3918 }), 3919 fulfillment_windows: vec![super::FulfillmentWindowRecord { 3920 fulfillment_window_id, 3921 farm_id, 3922 pickup_location_id, 3923 label: "Friday pickup".to_owned(), 3924 starts_at: "2026-04-25T14:00:00Z".to_owned(), 3925 ends_at: "2026-04-25T18:00:00Z".to_owned(), 3926 order_cutoff_at: "2026-04-24T18:00:00Z".to_owned(), 3927 }], 3928 blackout_periods: vec![super::BlackoutPeriodRecord { 3929 blackout_period_id, 3930 farm_id, 3931 label: "Spring break".to_owned(), 3932 starts_at: "2026-05-01T00:00:00Z".to_owned(), 3933 ends_at: "2026-05-03T23:59:59Z".to_owned(), 3934 }], 3935 readiness: FarmRulesReadiness::ready(), 3936 }; 3937 let saved_farm = super::FarmSummary { 3938 farm_id, 3939 display_name: "North field farm".to_owned(), 3940 readiness: super::FarmReadiness::Ready, 3941 }; 3942 3943 assert!(projection.is_ready()); 3944 assert_eq!( 3945 projection 3946 .farm_profile 3947 .as_ref() 3948 .map(|profile| profile.display_name.as_str()), 3949 Some(saved_farm.display_name.as_str()) 3950 ); 3951 assert_eq!( 3952 projection.pickup_locations[0].pickup_location_id, 3953 pickup_location_id 3954 ); 3955 assert_eq!( 3956 projection.fulfillment_windows[0].pickup_location_id, 3957 pickup_location_id 3958 ); 3959 assert_eq!( 3960 projection.blackout_periods[0].blackout_period_id, 3961 blackout_period_id 3962 ); 3963 assert_eq!(saved_farm.readiness, super::FarmReadiness::Ready); 3964 } 3965 3966 #[test] 3967 fn settings_preference_storage_keys_are_stable() { 3968 assert_eq!( 3969 SettingsPreference::AllowRelayConnections.storage_key(), 3970 "allow_relay_connections" 3971 ); 3972 assert_eq!( 3973 SettingsPreference::UseMediaServers.storage_key(), 3974 "use_media_servers" 3975 ); 3976 assert_eq!(SettingsPreference::UseNip05.storage_key(), "use_nip05"); 3977 assert_eq!( 3978 SettingsPreference::LaunchAtLogin.storage_key(), 3979 "launch_at_login" 3980 ); 3981 } 3982 3983 #[test] 3984 fn activity_kind_storage_keys_are_stable() { 3985 assert_eq!(AppActivityKind::HomeOpened.storage_key(), "home_opened"); 3986 assert_eq!( 3987 AppActivityKind::SettingsOpened { 3988 section: SettingsSection::About, 3989 } 3990 .storage_key(), 3991 "settings_opened" 3992 ); 3993 assert_eq!( 3994 AppActivityKind::SettingsSectionSelected { 3995 section: SettingsSection::Settings, 3996 } 3997 .storage_key(), 3998 "settings_section_selected" 3999 ); 4000 assert_eq!( 4001 AppActivityKind::SettingsPreferenceUpdated { 4002 preference: SettingsPreference::LaunchAtLogin, 4003 enabled: true, 4004 } 4005 .storage_key(), 4006 "settings_preference_updated" 4007 ); 4008 } 4009 4010 #[test] 4011 fn activity_context_preserves_recent_event_order() { 4012 let first = AppActivityEvent { 4013 activity_event_id: ActivityEventId::new(), 4014 recorded_at: "2026-04-18T00:00:00.000Z".to_owned(), 4015 kind: AppActivityKind::HomeOpened, 4016 }; 4017 let second = AppActivityEvent { 4018 activity_event_id: ActivityEventId::new(), 4019 recorded_at: "2026-04-18T00:01:00.000Z".to_owned(), 4020 kind: AppActivityKind::SettingsOpened { 4021 section: SettingsSection::About, 4022 }, 4023 }; 4024 let context = AppActivityContext::from_recent_events(vec![second.clone(), first.clone()]); 4025 4026 assert_eq!(context.recent_events, vec![second, first]); 4027 } 4028 }