app

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

farm_setup.rs (13478B)


      1 use std::collections::BTreeSet;
      2 
      3 use radroots_app_view::{
      4     FarmOrderMethod, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary,
      5 };
      6 use rusqlite::{Connection, OptionalExtension, params};
      7 
      8 use crate::AppSqliteError;
      9 
     10 pub struct AppFarmSetupRepository<'a> {
     11     connection: &'a Connection,
     12 }
     13 
     14 impl<'a> AppFarmSetupRepository<'a> {
     15     pub const fn new(connection: &'a Connection) -> Self {
     16         Self { connection }
     17     }
     18 
     19     pub fn load_farm_setup(&self, account_id: &str) -> Result<FarmSetupProjection, AppSqliteError> {
     20         let row = self
     21             .connection
     22             .query_row(
     23                 "SELECT
     24                     farm_name,
     25                     location_or_service_area,
     26                     pickup_enabled,
     27                     delivery_enabled,
     28                     shipping_enabled,
     29                     saved_farm_id,
     30                     saved_farm_display_name,
     31                     saved_farm_readiness
     32                  FROM account_farm_setups
     33                  WHERE account_id = ?1
     34                  LIMIT 1",
     35                 [account_id],
     36                 |row| {
     37                     Ok((
     38                         row.get::<_, String>(0)?,
     39                         row.get::<_, String>(1)?,
     40                         row.get::<_, i64>(2)?,
     41                         row.get::<_, i64>(3)?,
     42                         row.get::<_, i64>(4)?,
     43                         row.get::<_, Option<String>>(5)?,
     44                         row.get::<_, Option<String>>(6)?,
     45                         row.get::<_, Option<String>>(7)?,
     46                     ))
     47                 },
     48             )
     49             .optional()
     50             .map_err(|source| AppSqliteError::Query {
     51                 operation: "load account farm setup",
     52                 source,
     53             })?;
     54 
     55         let Some((
     56             farm_name,
     57             location_or_service_area,
     58             pickup_enabled,
     59             delivery_enabled,
     60             shipping_enabled,
     61             saved_farm_id,
     62             saved_farm_display_name,
     63             saved_farm_readiness,
     64         )) = row
     65         else {
     66             return Ok(FarmSetupProjection::not_started());
     67         };
     68 
     69         let mut order_methods = BTreeSet::new();
     70         if parse_sqlite_bool("account_farm_setups.pickup_enabled", pickup_enabled)? {
     71             order_methods.insert(FarmOrderMethod::Pickup);
     72         }
     73         if parse_sqlite_bool("account_farm_setups.delivery_enabled", delivery_enabled)? {
     74             order_methods.insert(FarmOrderMethod::Delivery);
     75         }
     76         if parse_sqlite_bool("account_farm_setups.shipping_enabled", shipping_enabled)? {
     77             order_methods.insert(FarmOrderMethod::Shipping);
     78         }
     79 
     80         let saved_farm =
     81             parse_saved_farm(saved_farm_id, saved_farm_display_name, saved_farm_readiness)?;
     82 
     83         Ok(FarmSetupProjection::new(
     84             FarmSetupDraft::new(farm_name, location_or_service_area, order_methods),
     85             saved_farm,
     86         ))
     87     }
     88 
     89     pub fn save_farm_setup(
     90         &self,
     91         account_id: &str,
     92         projection: &FarmSetupProjection,
     93     ) -> Result<(), AppSqliteError> {
     94         if !projection.has_saved_farm() && projection.draft.is_empty() {
     95             return self.clear_farm_setup(account_id);
     96         }
     97 
     98         self.connection
     99             .execute(
    100                 "INSERT INTO account_farm_setups (
    101                     account_id,
    102                     farm_name,
    103                     location_or_service_area,
    104                     pickup_enabled,
    105                     delivery_enabled,
    106                     shipping_enabled,
    107                     saved_farm_id,
    108                     saved_farm_display_name,
    109                     saved_farm_readiness,
    110                     updated_at
    111                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
    112                 ON CONFLICT(account_id) DO UPDATE SET
    113                     farm_name = excluded.farm_name,
    114                     location_or_service_area = excluded.location_or_service_area,
    115                     pickup_enabled = excluded.pickup_enabled,
    116                     delivery_enabled = excluded.delivery_enabled,
    117                     shipping_enabled = excluded.shipping_enabled,
    118                     saved_farm_id = excluded.saved_farm_id,
    119                     saved_farm_display_name = excluded.saved_farm_display_name,
    120                     saved_farm_readiness = excluded.saved_farm_readiness,
    121                     updated_at = excluded.updated_at",
    122                 params![
    123                     account_id,
    124                     projection.draft.farm_name,
    125                     projection.draft.location_or_service_area,
    126                     i64::from(
    127                         projection
    128                             .draft
    129                             .order_methods
    130                             .contains(&FarmOrderMethod::Pickup)
    131                     ),
    132                     i64::from(
    133                         projection
    134                             .draft
    135                             .order_methods
    136                             .contains(&FarmOrderMethod::Delivery)
    137                     ),
    138                     i64::from(
    139                         projection
    140                             .draft
    141                             .order_methods
    142                             .contains(&FarmOrderMethod::Shipping)
    143                     ),
    144                     projection
    145                         .saved_farm
    146                         .as_ref()
    147                         .map(|farm| farm.farm_id.to_string()),
    148                     projection
    149                         .saved_farm
    150                         .as_ref()
    151                         .map(|farm| farm.display_name.clone()),
    152                     projection
    153                         .saved_farm
    154                         .as_ref()
    155                         .map(|farm| farm_readiness_storage_key(farm.readiness)),
    156                 ],
    157             )
    158             .map_err(|source| AppSqliteError::Query {
    159                 operation: "save account farm setup",
    160                 source,
    161             })?;
    162 
    163         Ok(())
    164     }
    165 
    166     pub fn clear_farm_setup(&self, account_id: &str) -> Result<(), AppSqliteError> {
    167         self.connection
    168             .execute(
    169                 "DELETE FROM account_farm_setups WHERE account_id = ?1",
    170                 [account_id],
    171             )
    172             .map_err(|source| AppSqliteError::Query {
    173                 operation: "clear account farm setup",
    174                 source,
    175             })?;
    176 
    177         Ok(())
    178     }
    179 }
    180 
    181 fn parse_sqlite_bool(field: &'static str, value: i64) -> Result<bool, AppSqliteError> {
    182     match value {
    183         0 => Ok(false),
    184         1 => Ok(true),
    185         _ => Err(AppSqliteError::DecodeEnum {
    186             field,
    187             value: value.to_string(),
    188         }),
    189     }
    190 }
    191 
    192 fn parse_saved_farm(
    193     farm_id: Option<String>,
    194     display_name: Option<String>,
    195     readiness: Option<String>,
    196 ) -> Result<Option<FarmSummary>, AppSqliteError> {
    197     match (farm_id, display_name, readiness) {
    198         (Some(farm_id), Some(display_name), Some(readiness)) => Ok(Some(FarmSummary {
    199             farm_id: farm_id.parse().map_err(|_| AppSqliteError::DecodeId {
    200                 field: "account_farm_setups.saved_farm_id",
    201                 value: farm_id,
    202             })?,
    203             display_name,
    204             readiness: parse_farm_readiness("account_farm_setups.saved_farm_readiness", readiness)?,
    205         })),
    206         (None, None, None) => Ok(None),
    207         (Some(_), None, _) => Err(AppSqliteError::MissingColumn {
    208             field: "account_farm_setups.saved_farm_display_name",
    209         }),
    210         (Some(_), _, None) => Err(AppSqliteError::MissingColumn {
    211             field: "account_farm_setups.saved_farm_readiness",
    212         }),
    213         (None, Some(_), _) => Err(AppSqliteError::MissingColumn {
    214             field: "account_farm_setups.saved_farm_id",
    215         }),
    216         (None, _, Some(_)) => Err(AppSqliteError::MissingColumn {
    217             field: "account_farm_setups.saved_farm_id",
    218         }),
    219     }
    220 }
    221 
    222 fn parse_farm_readiness(
    223     field: &'static str,
    224     value: String,
    225 ) -> Result<FarmReadiness, AppSqliteError> {
    226     match value.as_str() {
    227         "incomplete" => Ok(FarmReadiness::Incomplete),
    228         "ready" => Ok(FarmReadiness::Ready),
    229         _ => Err(AppSqliteError::DecodeEnum { field, value }),
    230     }
    231 }
    232 
    233 fn farm_readiness_storage_key(readiness: FarmReadiness) -> &'static str {
    234     match readiness {
    235         FarmReadiness::Incomplete => "incomplete",
    236         FarmReadiness::Ready => "ready",
    237     }
    238 }
    239 
    240 #[cfg(test)]
    241 mod tests {
    242     use std::{
    243         env, fs,
    244         path::PathBuf,
    245         time::{SystemTime, UNIX_EPOCH},
    246     };
    247 
    248     use radroots_app_view::{
    249         FarmId, FarmOrderMethod, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary,
    250     };
    251 
    252     use crate::{AppSqliteStore, DatabaseTarget};
    253 
    254     #[test]
    255     fn load_farm_setup_returns_not_started_when_account_is_missing() {
    256         let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
    257 
    258         let projection = store
    259             .load_farm_setup("acct_missing")
    260             .expect("missing setup should load");
    261 
    262         assert_eq!(projection, FarmSetupProjection::not_started());
    263     }
    264 
    265     #[test]
    266     fn farm_setup_round_trips_incomplete_draft() {
    267         let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
    268         let projection = FarmSetupProjection::from_draft(FarmSetupDraft::new(
    269             "North field farm",
    270             "",
    271             [FarmOrderMethod::Pickup, FarmOrderMethod::Shipping],
    272         ));
    273 
    274         store
    275             .save_farm_setup("acct_farm_draft", &projection)
    276             .expect("farm setup should save");
    277 
    278         let loaded = store
    279             .load_farm_setup("acct_farm_draft")
    280             .expect("farm setup should load");
    281 
    282         assert_eq!(loaded, projection);
    283     }
    284 
    285     #[test]
    286     fn farm_setup_round_trips_saved_farm_state() {
    287         let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
    288         let saved_farm = FarmSummary {
    289             farm_id: FarmId::new(),
    290             display_name: "North field farm".to_owned(),
    291             readiness: FarmReadiness::Ready,
    292         };
    293         let projection = FarmSetupProjection::new(
    294             FarmSetupDraft::new(
    295                 "North field farm",
    296                 "Asheville, NC",
    297                 [FarmOrderMethod::Pickup],
    298             ),
    299             Some(saved_farm.clone()),
    300         );
    301 
    302         store
    303             .save_farm_setup("acct_saved_farm", &projection)
    304             .expect("saved farm setup should save");
    305 
    306         let loaded = store
    307             .load_farm_setup("acct_saved_farm")
    308             .expect("saved farm setup should load");
    309 
    310         assert_eq!(loaded.saved_farm, Some(saved_farm));
    311         assert_eq!(loaded.readiness, projection.readiness);
    312         assert_eq!(loaded.draft, projection.draft);
    313     }
    314 
    315     #[test]
    316     fn clearing_farm_setup_restores_not_started_state() {
    317         let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
    318 
    319         store
    320             .save_farm_setup(
    321                 "acct_clear",
    322                 &FarmSetupProjection::from_draft(FarmSetupDraft::new(
    323                     "North field farm",
    324                     "Asheville, NC",
    325                     [FarmOrderMethod::Delivery],
    326                 )),
    327             )
    328             .expect("farm setup should save");
    329         store
    330             .clear_farm_setup("acct_clear")
    331             .expect("farm setup should clear");
    332 
    333         assert_eq!(
    334             store
    335                 .load_farm_setup("acct_clear")
    336                 .expect("cleared setup should load"),
    337             FarmSetupProjection::not_started()
    338         );
    339     }
    340 
    341     #[test]
    342     fn file_backed_farm_setup_survives_reopen() {
    343         let path = temp_database_path("farm_setup_reopen");
    344         let projection = FarmSetupProjection::from_draft(FarmSetupDraft::new(
    345             "North field farm",
    346             "Asheville, NC",
    347             [FarmOrderMethod::Pickup, FarmOrderMethod::Delivery],
    348         ));
    349 
    350         let first = AppSqliteStore::open(DatabaseTarget::Path(path.clone())).expect("store");
    351         first
    352             .save_farm_setup("acct_file_backed", &projection)
    353             .expect("farm setup should save");
    354         drop(first);
    355 
    356         let reopened = AppSqliteStore::open(DatabaseTarget::Path(path.clone())).expect("reopen");
    357         let loaded = reopened
    358             .load_farm_setup("acct_file_backed")
    359             .expect("reloaded setup should load");
    360 
    361         assert_eq!(loaded, projection);
    362 
    363         drop(reopened);
    364         remove_database_artifacts(&path);
    365     }
    366 
    367     fn temp_database_path(test_name: &str) -> PathBuf {
    368         let nonce = SystemTime::now()
    369             .duration_since(UNIX_EPOCH)
    370             .expect("time should move forward")
    371             .as_nanos();
    372 
    373         env::temp_dir()
    374             .join("radroots_app_sqlite_tests")
    375             .join(format!("{test_name}-{nonce}"))
    376             .join("app.sqlite3")
    377     }
    378 
    379     fn remove_database_artifacts(database_path: &std::path::Path) {
    380         if let Some(parent) = database_path.parent() {
    381             let wal_path = database_path.with_extension("sqlite3-wal");
    382             let shm_path = database_path.with_extension("sqlite3-shm");
    383 
    384             let _ = fs::remove_file(&wal_path);
    385             let _ = fs::remove_file(&shm_path);
    386             let _ = fs::remove_file(database_path);
    387             let _ = fs::remove_dir_all(parent);
    388         }
    389     }
    390 }