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 }