app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit b5b3f59490fe14988016bd705d1f3a799306ac73
parent 756b9673b44793d8cfa75cbbaf6b5a2b117b3c6f
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Apr 2026 23:18:30 +0000

models: add farm rules contracts

Diffstat:
Mcrates/shared/models/src/lib.rs | 314+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 306 insertions(+), 8 deletions(-)

diff --git a/crates/shared/models/src/lib.rs b/crates/shared/models/src/lib.rs @@ -202,6 +202,8 @@ macro_rules! typed_id { } typed_id!(FarmId); +typed_id!(PickupLocationId); +typed_id!(BlackoutPeriodId); typed_id!(ProductId); typed_id!(OrderId); typed_id!(FulfillmentWindowId); @@ -647,6 +649,165 @@ pub enum FarmReadiness { Ready, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct FarmProfileRecord { + pub farm_id: FarmId, + pub display_name: String, + pub timezone: String, + pub currency_code: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct FarmOperatingRulesRecord { + pub farm_id: FarmId, + pub promise_lead_hours: u16, + pub substitution_policy: String, + pub missed_pickup_policy: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PickupLocationRecord { + pub pickup_location_id: PickupLocationId, + pub farm_id: FarmId, + pub label: String, + pub address_line: String, + pub directions: Option<String>, + pub is_default: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct FulfillmentWindowRecord { + pub fulfillment_window_id: FulfillmentWindowId, + pub farm_id: FarmId, + pub pickup_location_id: PickupLocationId, + pub label: String, + pub starts_at: String, + pub ends_at: String, + pub order_cutoff_at: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct BlackoutPeriodRecord { + pub blackout_period_id: BlackoutPeriodId, + pub farm_id: FarmId, + pub label: String, + pub starts_at: String, + pub ends_at: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FarmReadinessBlocker { + MissingProfileBasics, + MissingPickupLocation, + MissingFulfillmentWindow, + MissingOperatingRules, +} + +impl FarmReadinessBlocker { + pub const fn storage_key(self) -> &'static str { + match self { + Self::MissingProfileBasics => "missing_profile_basics", + Self::MissingPickupLocation => "missing_pickup_location", + Self::MissingFulfillmentWindow => "missing_fulfillment_window", + Self::MissingOperatingRules => "missing_operating_rules", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FarmTimingConflictKind { + FulfillmentWindowEndsBeforeStart, + FulfillmentWindowCutoffAfterStart, + BlackoutPeriodEndsBeforeStart, + BlackoutOverlapsFulfillmentWindow, +} + +impl FarmTimingConflictKind { + pub const fn storage_key(self) -> &'static str { + match self { + Self::FulfillmentWindowEndsBeforeStart => "fulfillment_window_ends_before_start", + Self::FulfillmentWindowCutoffAfterStart => "fulfillment_window_cutoff_after_start", + Self::BlackoutPeriodEndsBeforeStart => "blackout_period_ends_before_start", + Self::BlackoutOverlapsFulfillmentWindow => "blackout_overlaps_fulfillment_window", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct FarmTimingConflict { + pub kind: FarmTimingConflictKind, + pub fulfillment_window_id: Option<FulfillmentWindowId>, + pub blackout_period_id: Option<BlackoutPeriodId>, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct FarmRulesReadiness { + pub blockers: Vec<FarmReadinessBlocker>, + pub timing_conflicts: Vec<FarmTimingConflict>, +} + +impl FarmRulesReadiness { + pub fn ready() -> Self { + Self { + blockers: Vec::new(), + timing_conflicts: Vec::new(), + } + } + + pub fn missing_v1_basics() -> Self { + Self { + blockers: vec![ + FarmReadinessBlocker::MissingProfileBasics, + FarmReadinessBlocker::MissingPickupLocation, + FarmReadinessBlocker::MissingFulfillmentWindow, + FarmReadinessBlocker::MissingOperatingRules, + ], + timing_conflicts: Vec::new(), + } + } + + pub fn is_ready(&self) -> bool { + self.blockers.is_empty() && self.timing_conflicts.is_empty() + } +} + +impl Default for FarmRulesReadiness { + fn default() -> Self { + Self::ready() + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct FarmRulesProjection { + pub farm_profile: Option<FarmProfileRecord>, + pub pickup_locations: Vec<PickupLocationRecord>, + pub operating_rules: Option<FarmOperatingRulesRecord>, + pub fulfillment_windows: Vec<FulfillmentWindowRecord>, + pub blackout_periods: Vec<BlackoutPeriodRecord>, + pub readiness: FarmRulesReadiness, +} + +impl Default for FarmRulesProjection { + fn default() -> Self { + Self { + farm_profile: None, + pickup_locations: Vec::new(), + operating_rules: None, + fulfillment_windows: Vec::new(), + blackout_periods: Vec::new(), + readiness: FarmRulesReadiness::missing_v1_basics(), + } + } +} + +impl FarmRulesProjection { + pub fn is_ready(&self) -> bool { + self.readiness.is_ready() + } +} + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ProductStatus { @@ -1276,16 +1437,19 @@ mod tests { use super::{ AccountCustody, AccountSummary, AccountSurfaceActivationProjection, ActiveSurface, ActivityEventId, AppActivityContext, AppActivityEvent, AppActivityKind, - AppIdentityProjection, AppStartupGate, FarmId, FarmOrderMethod, FarmSetupBlocker, - FarmSetupDraft, FarmSetupProjection, FarmSetupReadiness, FarmSetupSection, + AppIdentityProjection, AppStartupGate, BlackoutPeriodId, FarmId, + FarmOrderMethod, FarmReadinessBlocker, FarmRulesProjection, FarmRulesReadiness, + FarmSetupBlocker, FarmSetupDraft, FarmSetupProjection, FarmSetupReadiness, + FarmSetupSection, FarmTimingConflict, FarmTimingConflictKind, FarmerActivationProjection, FarmerSection, IdentityBlockedReason, IdentityReadiness, LoggedOutStartupPhase, LoggedOutStartupProjection, OrderListRow, - ParseStartupSignerSourceError, ProductAttentionState, ProductAvailabilityState, - ProductAvailabilitySummary, ProductEditorDraft, ProductListRow, ProductPricePresentation, - ProductPublishBlocker, ProductStatus, ProductStockState, ProductStockSummary, - ProductsFilter, ProductsListProjection, ProductsListRow, ProductsListSummary, ProductsSort, - SelectedAccountProjection, SelectedSurfaceProjection, SettingsPreference, SettingsSection, - ShellSection, StartupSignerEntryProjection, StartupSignerSource, StartupSignerSourceKind, + ParseStartupSignerSourceError, PickupLocationId, ProductAttentionState, + ProductAvailabilityState, ProductAvailabilitySummary, ProductEditorDraft, ProductListRow, + ProductPricePresentation, ProductPublishBlocker, ProductStatus, ProductStockState, + ProductStockSummary, ProductsFilter, ProductsListProjection, ProductsListRow, + ProductsListSummary, ProductsSort, SelectedAccountProjection, + SelectedSurfaceProjection, SettingsPreference, SettingsSection, ShellSection, + StartupSignerEntryProjection, StartupSignerSource, StartupSignerSourceKind, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, }; use std::{collections::BTreeSet, str::FromStr}; @@ -1893,6 +2057,140 @@ mod tests { } #[test] + fn farm_rules_projection_defaults_to_missing_v1_requirements() { + let projection = FarmRulesProjection::default(); + + assert!(projection.farm_profile.is_none()); + assert!(projection.pickup_locations.is_empty()); + assert!(projection.operating_rules.is_none()); + assert!(projection.fulfillment_windows.is_empty()); + assert!(projection.blackout_periods.is_empty()); + assert_eq!( + projection.readiness, + FarmRulesReadiness::missing_v1_basics() + ); + assert!(!projection.is_ready()); + } + + #[test] + fn farm_rules_readiness_and_timing_conflicts_are_explicit() { + let readiness = FarmRulesReadiness { + blockers: vec![FarmReadinessBlocker::MissingOperatingRules], + timing_conflicts: vec![FarmTimingConflict { + kind: FarmTimingConflictKind::BlackoutOverlapsFulfillmentWindow, + fulfillment_window_id: Some(super::FulfillmentWindowId::new()), + blackout_period_id: Some(BlackoutPeriodId::new()), + }], + }; + + assert_eq!( + FarmReadinessBlocker::MissingProfileBasics.storage_key(), + "missing_profile_basics" + ); + assert_eq!( + FarmReadinessBlocker::MissingPickupLocation.storage_key(), + "missing_pickup_location" + ); + assert_eq!( + FarmReadinessBlocker::MissingFulfillmentWindow.storage_key(), + "missing_fulfillment_window" + ); + assert_eq!( + FarmReadinessBlocker::MissingOperatingRules.storage_key(), + "missing_operating_rules" + ); + assert_eq!( + FarmTimingConflictKind::FulfillmentWindowEndsBeforeStart.storage_key(), + "fulfillment_window_ends_before_start" + ); + assert_eq!( + FarmTimingConflictKind::FulfillmentWindowCutoffAfterStart.storage_key(), + "fulfillment_window_cutoff_after_start" + ); + assert_eq!( + FarmTimingConflictKind::BlackoutPeriodEndsBeforeStart.storage_key(), + "blackout_period_ends_before_start" + ); + assert_eq!( + FarmTimingConflictKind::BlackoutOverlapsFulfillmentWindow.storage_key(), + "blackout_overlaps_fulfillment_window" + ); + assert!(!readiness.is_ready()); + assert!(FarmRulesReadiness::ready().is_ready()); + } + + #[test] + fn farm_rules_projection_represents_full_v1_inventory() { + let farm_id = FarmId::new(); + let pickup_location_id = PickupLocationId::new(); + let fulfillment_window_id = super::FulfillmentWindowId::new(); + let blackout_period_id = BlackoutPeriodId::new(); + let projection = super::FarmRulesProjection { + farm_profile: Some(super::FarmProfileRecord { + farm_id, + display_name: "North field farm".to_owned(), + timezone: "UTC".to_owned(), + currency_code: "USD".to_owned(), + }), + pickup_locations: vec![super::PickupLocationRecord { + pickup_location_id, + farm_id, + label: "Barn pickup".to_owned(), + address_line: "14 Orchard Lane".to_owned(), + directions: Some("Drive to the red barn.".to_owned()), + is_default: true, + }], + operating_rules: Some(super::FarmOperatingRulesRecord { + farm_id, + promise_lead_hours: 24, + substitution_policy: "ask_customer".to_owned(), + missed_pickup_policy: "hold_next_window".to_owned(), + }), + fulfillment_windows: vec![super::FulfillmentWindowRecord { + fulfillment_window_id, + farm_id, + pickup_location_id, + label: "Friday pickup".to_owned(), + starts_at: "2026-04-25T14:00:00Z".to_owned(), + ends_at: "2026-04-25T18:00:00Z".to_owned(), + order_cutoff_at: "2026-04-24T18:00:00Z".to_owned(), + }], + blackout_periods: vec![super::BlackoutPeriodRecord { + blackout_period_id, + farm_id, + label: "Spring break".to_owned(), + starts_at: "2026-05-01T00:00:00Z".to_owned(), + ends_at: "2026-05-03T23:59:59Z".to_owned(), + }], + readiness: FarmRulesReadiness::ready(), + }; + let saved_farm = super::FarmSummary { + farm_id, + display_name: "North field farm".to_owned(), + readiness: super::FarmReadiness::Ready, + }; + + assert!(projection.is_ready()); + assert_eq!( + projection + .farm_profile + .as_ref() + .map(|profile| profile.display_name.as_str()), + Some(saved_farm.display_name.as_str()) + ); + assert_eq!(projection.pickup_locations[0].pickup_location_id, pickup_location_id); + assert_eq!( + projection.fulfillment_windows[0].pickup_location_id, + pickup_location_id + ); + assert_eq!( + projection.blackout_periods[0].blackout_period_id, + blackout_period_id + ); + assert_eq!(saved_farm.readiness, super::FarmReadiness::Ready); + } + + #[test] fn settings_preference_storage_keys_are_stable() { assert_eq!( SettingsPreference::AllowRelayConnections.storage_key(),