app

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

today.rs (26166B)


      1 use radroots_app_view::{
      2     FarmId, FarmReadiness, FarmSummary, FulfillmentWindowSummary, OrderListRow, OrderStatus,
      3     ProductListRow, ProductStatus, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind,
      4     TodaySummary,
      5 };
      6 use rusqlite::{Connection, OptionalExtension, Params, params};
      7 
      8 use crate::AppSqliteError;
      9 
     10 pub const TODAY_AGENDA_LIST_LIMIT: i64 = 4;
     11 pub const TODAY_AGENDA_LOW_STOCK_THRESHOLD: u32 = 3;
     12 
     13 pub struct AppTodayAgendaRepository<'a> {
     14     connection: &'a Connection,
     15 }
     16 
     17 impl<'a> AppTodayAgendaRepository<'a> {
     18     pub const fn new(connection: &'a Connection) -> Self {
     19         Self { connection }
     20     }
     21 
     22     pub fn load(&self, farm_id: Option<FarmId>) -> Result<TodayAgendaProjection, AppSqliteError> {
     23         let Some(farm) = self.load_farm_summary(farm_id)? else {
     24             return Ok(TodayAgendaProjection::default());
     25         };
     26 
     27         Ok(TodayAgendaProjection {
     28             farm: Some(farm.clone()),
     29             summary: Some(self.load_today_summary(farm.farm_id)?),
     30             reminders: Default::default(),
     31             orders_needing_action: self.load_orders_needing_action(farm.farm_id)?,
     32             low_stock_products: self.load_low_stock_products(farm.farm_id)?,
     33             draft_products: self.load_draft_products(farm.farm_id)?,
     34             next_fulfillment_window: self.load_next_fulfillment_window(farm.farm_id)?,
     35             setup_checklist: self.load_setup_checklist(&farm)?,
     36         })
     37     }
     38 
     39     pub fn save_farm_summary(&self, farm: &FarmSummary) -> Result<(), AppSqliteError> {
     40         self.connection
     41             .execute(
     42                 "insert into farms (id, display_name, readiness, created_at, updated_at)
     43                  values (
     44                     ?1,
     45                     ?2,
     46                     ?3,
     47                     strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
     48                     strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
     49                  )
     50                  on conflict(id) do update set
     51                     display_name = excluded.display_name,
     52                     readiness = excluded.readiness,
     53                     updated_at = excluded.updated_at",
     54                 params![
     55                     farm.farm_id.to_string(),
     56                     farm.display_name,
     57                     farm_readiness_storage_key(farm.readiness),
     58                 ],
     59             )
     60             .map_err(|source| AppSqliteError::Query {
     61                 operation: "save today farm summary",
     62                 source,
     63             })?;
     64 
     65         Ok(())
     66     }
     67 
     68     fn load_farm_summary(
     69         &self,
     70         farm_id: Option<FarmId>,
     71     ) -> Result<Option<FarmSummary>, AppSqliteError> {
     72         let farm_row = if let Some(farm_id) = farm_id {
     73             self.connection
     74                 .query_row(
     75                     "select id, display_name, readiness from farms where id = ?1 limit 1",
     76                     params![farm_id.to_string()],
     77                     |row| {
     78                         Ok((
     79                             row.get::<_, String>(0)?,
     80                             row.get::<_, String>(1)?,
     81                             row.get::<_, String>(2)?,
     82                         ))
     83                     },
     84                 )
     85                 .optional()
     86                 .map_err(|source| AppSqliteError::Query {
     87                     operation: "load today farm summary",
     88                     source,
     89                 })?
     90         } else {
     91             self.connection
     92                 .query_row(
     93                     "select id, display_name, readiness from farms order by created_at asc, id asc limit 1",
     94                     [],
     95                     |row| {
     96                         Ok((
     97                             row.get::<_, String>(0)?,
     98                             row.get::<_, String>(1)?,
     99                             row.get::<_, String>(2)?,
    100                         ))
    101                     },
    102                 )
    103                 .optional()
    104                 .map_err(|source| AppSqliteError::Query {
    105                     operation: "load today farm summary",
    106                     source,
    107                 })?
    108         };
    109 
    110         farm_row
    111             .map(|(farm_id, display_name, readiness)| {
    112                 Ok(FarmSummary {
    113                     farm_id: parse_typed_id("farms.id", farm_id)?,
    114                     display_name,
    115                     readiness: parse_farm_readiness("farms.readiness", readiness)?,
    116                 })
    117             })
    118             .transpose()
    119     }
    120 
    121     fn load_today_summary(&self, farm_id: FarmId) -> Result<TodaySummary, AppSqliteError> {
    122         Ok(TodaySummary {
    123             farm_id,
    124             orders_needing_action: self.count_u32(
    125                 "count today orders needing action",
    126                 "select count(*) from orders where farm_id = ?1 and status = 'needs_action'",
    127                 params![farm_id.to_string()],
    128             )?,
    129             low_stock_products: self.count_u32(
    130                 "count today low-stock products",
    131                 "select count(*) from products where farm_id = ?1 and status = 'published' and stock_count <= ?2",
    132                 params![farm_id.to_string(), TODAY_AGENDA_LOW_STOCK_THRESHOLD],
    133             )?,
    134             draft_products: self.count_u32(
    135                 "count today draft products",
    136                 "select count(*) from products where farm_id = ?1 and status = 'draft'",
    137                 params![farm_id.to_string()],
    138             )?,
    139             reminders_due_soon: 0,
    140         })
    141     }
    142 
    143     fn load_orders_needing_action(
    144         &self,
    145         farm_id: FarmId,
    146     ) -> Result<Vec<OrderListRow>, AppSqliteError> {
    147         let mut statement = self
    148             .connection
    149             .prepare(
    150                 "select id, fulfillment_window_id, order_number, customer_display_name \
    151                  from orders \
    152                  where farm_id = ?1 and status = 'needs_action' \
    153                  order by updated_at desc, id desc \
    154                  limit ?2",
    155             )
    156             .map_err(|source| AppSqliteError::Query {
    157                 operation: "prepare today orders needing action",
    158                 source,
    159             })?;
    160         let rows = statement
    161             .query_map(
    162                 params![farm_id.to_string(), TODAY_AGENDA_LIST_LIMIT],
    163                 |row| {
    164                     Ok((
    165                         row.get::<_, String>(0)?,
    166                         row.get::<_, Option<String>>(1)?,
    167                         row.get::<_, String>(2)?,
    168                         row.get::<_, String>(3)?,
    169                     ))
    170                 },
    171             )
    172             .map_err(|source| AppSqliteError::Query {
    173                 operation: "query today orders needing action",
    174                 source,
    175             })?;
    176         let mut orders = Vec::new();
    177 
    178         for row in rows {
    179             let (order_id, fulfillment_window_id, order_number, customer_display_name) = row
    180                 .map_err(|source| AppSqliteError::Query {
    181                     operation: "read today orders needing action",
    182                     source,
    183                 })?;
    184 
    185             orders.push(OrderListRow {
    186                 order_id: parse_typed_id("orders.id", order_id)?,
    187                 farm_id,
    188                 fulfillment_window_id: parse_optional_typed_id(
    189                     "orders.fulfillment_window_id",
    190                     fulfillment_window_id,
    191                 )?,
    192                 order_number,
    193                 customer_display_name,
    194                 status: OrderStatus::NeedsAction,
    195             });
    196         }
    197 
    198         Ok(orders)
    199     }
    200 
    201     fn load_low_stock_products(
    202         &self,
    203         farm_id: FarmId,
    204     ) -> Result<Vec<ProductListRow>, AppSqliteError> {
    205         let mut statement = self
    206             .connection
    207             .prepare(
    208                 "select id, title, coalesce(stock_count, 0) \
    209                  from products \
    210                  where farm_id = ?1 and status = 'published' and stock_count <= ?2 \
    211                  order by stock_count asc, updated_at desc, id desc \
    212                  limit ?3",
    213             )
    214             .map_err(|source| AppSqliteError::Query {
    215                 operation: "prepare today low-stock products",
    216                 source,
    217             })?;
    218         let rows = statement
    219             .query_map(
    220                 params![
    221                     farm_id.to_string(),
    222                     TODAY_AGENDA_LOW_STOCK_THRESHOLD,
    223                     TODAY_AGENDA_LIST_LIMIT
    224                 ],
    225                 |row| {
    226                     Ok((
    227                         row.get::<_, String>(0)?,
    228                         row.get::<_, String>(1)?,
    229                         row.get::<_, u32>(2)?,
    230                     ))
    231                 },
    232             )
    233             .map_err(|source| AppSqliteError::Query {
    234                 operation: "query today low-stock products",
    235                 source,
    236             })?;
    237         let mut products = Vec::new();
    238 
    239         for row in rows {
    240             let (product_id, title, stock_count) = row.map_err(|source| AppSqliteError::Query {
    241                 operation: "read today low-stock products",
    242                 source,
    243             })?;
    244 
    245             products.push(ProductListRow {
    246                 product_id: parse_typed_id("products.id", product_id)?,
    247                 farm_id,
    248                 title,
    249                 status: ProductStatus::Published,
    250                 stock_count,
    251             });
    252         }
    253 
    254         Ok(products)
    255     }
    256 
    257     fn load_draft_products(&self, farm_id: FarmId) -> Result<Vec<ProductListRow>, AppSqliteError> {
    258         let mut statement = self
    259             .connection
    260             .prepare(
    261                 "select id, title, coalesce(stock_count, 0) \
    262                  from products \
    263                  where farm_id = ?1 and status = 'draft' \
    264                  order by updated_at desc, id desc \
    265                  limit ?2",
    266             )
    267             .map_err(|source| AppSqliteError::Query {
    268                 operation: "prepare today draft products",
    269                 source,
    270             })?;
    271         let rows = statement
    272             .query_map(
    273                 params![farm_id.to_string(), TODAY_AGENDA_LIST_LIMIT],
    274                 |row| {
    275                     Ok((
    276                         row.get::<_, String>(0)?,
    277                         row.get::<_, String>(1)?,
    278                         row.get::<_, u32>(2)?,
    279                     ))
    280                 },
    281             )
    282             .map_err(|source| AppSqliteError::Query {
    283                 operation: "query today draft products",
    284                 source,
    285             })?;
    286         let mut products = Vec::new();
    287 
    288         for row in rows {
    289             let (product_id, title, stock_count) = row.map_err(|source| AppSqliteError::Query {
    290                 operation: "read today draft products",
    291                 source,
    292             })?;
    293 
    294             products.push(ProductListRow {
    295                 product_id: parse_typed_id("products.id", product_id)?,
    296                 farm_id,
    297                 title,
    298                 status: ProductStatus::Draft,
    299                 stock_count,
    300             });
    301         }
    302 
    303         Ok(products)
    304     }
    305 
    306     fn load_next_fulfillment_window(
    307         &self,
    308         farm_id: FarmId,
    309     ) -> Result<Option<FulfillmentWindowSummary>, AppSqliteError> {
    310         self.connection
    311             .query_row(
    312                 "select id, starts_at, ends_at \
    313                  from fulfillment_windows \
    314                  where farm_id = ?1 and starts_at >= strftime('%Y-%m-%dT%H:%M:%SZ', 'now') \
    315                  order by starts_at asc, id asc \
    316                  limit 1",
    317                 params![farm_id.to_string()],
    318                 |row| {
    319                     Ok((
    320                         row.get::<_, String>(0)?,
    321                         row.get::<_, String>(1)?,
    322                         row.get::<_, String>(2)?,
    323                     ))
    324                 },
    325             )
    326             .optional()
    327             .map_err(|source| AppSqliteError::Query {
    328                 operation: "load today next fulfillment window",
    329                 source,
    330             })?
    331             .map(|(fulfillment_window_id, starts_at, ends_at)| {
    332                 Ok(FulfillmentWindowSummary {
    333                     fulfillment_window_id: parse_typed_id(
    334                         "fulfillment_windows.id",
    335                         fulfillment_window_id,
    336                     )?,
    337                     farm_id,
    338                     starts_at,
    339                     ends_at,
    340                 })
    341             })
    342             .transpose()
    343     }
    344 
    345     fn load_setup_checklist(
    346         &self,
    347         farm: &FarmSummary,
    348     ) -> Result<Vec<TodaySetupTask>, AppSqliteError> {
    349         if farm.readiness != FarmReadiness::Incomplete {
    350             return Ok(Vec::new());
    351         }
    352 
    353         Ok(vec![
    354             TodaySetupTask {
    355                 kind: TodaySetupTaskKind::AddFulfillmentWindow,
    356                 is_complete: self.exists(
    357                     "check today fulfillment window setup",
    358                     "select exists(select 1 from fulfillment_windows where farm_id = ?1)",
    359                     params![farm.farm_id.to_string()],
    360                 )?,
    361             },
    362             TodaySetupTask {
    363                 kind: TodaySetupTaskKind::PublishProduct,
    364                 is_complete: self.exists(
    365                     "check today published product setup",
    366                     "select exists(select 1 from products where farm_id = ?1 and status = 'published')",
    367                     params![farm.farm_id.to_string()],
    368                 )?,
    369             },
    370         ])
    371     }
    372 
    373     fn count_u32<P: Params>(
    374         &self,
    375         operation: &'static str,
    376         sql: &'static str,
    377         params: P,
    378     ) -> Result<u32, AppSqliteError> {
    379         self.connection
    380             .query_row(sql, params, |row| row.get::<_, u32>(0))
    381             .map_err(|source| AppSqliteError::Query { operation, source })
    382     }
    383 
    384     fn exists<P: Params>(
    385         &self,
    386         operation: &'static str,
    387         sql: &'static str,
    388         params: P,
    389     ) -> Result<bool, AppSqliteError> {
    390         self.connection
    391             .query_row(sql, params, |row| row.get::<_, i64>(0))
    392             .map(|value| value == 1)
    393             .map_err(|source| AppSqliteError::Query { operation, source })
    394     }
    395 }
    396 
    397 fn parse_typed_id<T>(field: &'static str, value: String) -> Result<T, AppSqliteError>
    398 where
    399     T: std::str::FromStr,
    400 {
    401     value
    402         .parse()
    403         .map_err(|_| AppSqliteError::DecodeId { field, value })
    404 }
    405 
    406 fn parse_optional_typed_id<T>(
    407     field: &'static str,
    408     value: Option<String>,
    409 ) -> Result<Option<T>, AppSqliteError>
    410 where
    411     T: std::str::FromStr,
    412 {
    413     value.map(|value| parse_typed_id(field, value)).transpose()
    414 }
    415 
    416 fn parse_farm_readiness(
    417     field: &'static str,
    418     value: String,
    419 ) -> Result<FarmReadiness, AppSqliteError> {
    420     match value.as_str() {
    421         "incomplete" => Ok(FarmReadiness::Incomplete),
    422         "ready" => Ok(FarmReadiness::Ready),
    423         _ => Err(AppSqliteError::DecodeEnum { field, value }),
    424     }
    425 }
    426 
    427 fn farm_readiness_storage_key(readiness: FarmReadiness) -> &'static str {
    428     match readiness {
    429         FarmReadiness::Incomplete => "incomplete",
    430         FarmReadiness::Ready => "ready",
    431     }
    432 }
    433 
    434 #[cfg(test)]
    435 mod tests {
    436     use radroots_app_view::{FarmId, FulfillmentWindowId, ProductId, TodaySetupTaskKind};
    437     use rusqlite::{Connection, params};
    438 
    439     use crate::{AppSqliteStore, DatabaseTarget};
    440 
    441     use super::{TODAY_AGENDA_LIST_LIMIT, TODAY_AGENDA_LOW_STOCK_THRESHOLD};
    442 
    443     #[test]
    444     fn today_agenda_returns_default_when_no_farm_exists() {
    445         let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
    446 
    447         let projection = store
    448             .load_today_agenda(None)
    449             .expect("empty today agenda should load");
    450 
    451         assert_eq!(
    452             projection,
    453             radroots_app_view::TodayAgendaProjection::default()
    454         );
    455     }
    456 
    457     #[test]
    458     fn today_agenda_loads_truthful_projection_for_selected_farm() {
    459         let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
    460         let connection = store.connection();
    461         let farm_id = FarmId::new();
    462         let other_farm_id = FarmId::new();
    463         let earliest_window_id = FulfillmentWindowId::new();
    464         let later_window_id = FulfillmentWindowId::new();
    465 
    466         insert_farm(
    467             connection,
    468             farm_id,
    469             "Willow Farm",
    470             "ready",
    471             "2026-04-17T08:00:00Z",
    472         );
    473         insert_farm(
    474             connection,
    475             other_farm_id,
    476             "Other Farm",
    477             "ready",
    478             "2026-04-18T08:00:00Z",
    479         );
    480         insert_window(
    481             connection,
    482             earliest_window_id,
    483             farm_id,
    484             "2099-04-18T16:00:00Z",
    485             "2099-04-18T18:00:00Z",
    486         );
    487         insert_window(
    488             connection,
    489             later_window_id,
    490             farm_id,
    491             "2099-04-19T16:00:00Z",
    492             "2099-04-19T18:00:00Z",
    493         );
    494         insert_window(
    495             connection,
    496             FulfillmentWindowId::new(),
    497             other_farm_id,
    498             "2099-04-17T10:00:00Z",
    499             "2099-04-17T12:00:00Z",
    500         );
    501 
    502         for index in 0..5 {
    503             insert_order(
    504                 connection,
    505                 farm_id,
    506                 Some(earliest_window_id),
    507                 &format!("R-10{index}"),
    508                 "Casey",
    509                 "needs_action",
    510                 &format!("2026-04-17T0{index}:00:00Z"),
    511             );
    512         }
    513         insert_order(
    514             connection,
    515             farm_id,
    516             Some(earliest_window_id),
    517             "R-200",
    518             "Taylor",
    519             "scheduled",
    520             "2026-04-17T11:00:00Z",
    521         );
    522         insert_order(
    523             connection,
    524             other_farm_id,
    525             None,
    526             "R-999",
    527             "Other",
    528             "needs_action",
    529             "2026-04-17T12:00:00Z",
    530         );
    531 
    532         insert_product(
    533             connection,
    534             farm_id,
    535             "Carrots",
    536             "published",
    537             1,
    538             "2026-04-17T10:00:00Z",
    539         );
    540         insert_product(
    541             connection,
    542             farm_id,
    543             "Greens",
    544             "published",
    545             TODAY_AGENDA_LOW_STOCK_THRESHOLD,
    546             "2026-04-17T09:00:00Z",
    547         );
    548         insert_product(
    549             connection,
    550             farm_id,
    551             "Tomatoes",
    552             "published",
    553             TODAY_AGENDA_LOW_STOCK_THRESHOLD + 1,
    554             "2026-04-17T08:00:00Z",
    555         );
    556         for index in 0..5 {
    557             insert_product(
    558                 connection,
    559                 farm_id,
    560                 &format!("Draft {index}"),
    561                 "draft",
    562                 0,
    563                 &format!("2026-04-17T1{index}:00:00Z"),
    564             );
    565         }
    566         insert_product(
    567             connection,
    568             other_farm_id,
    569             "Other Draft",
    570             "draft",
    571             0,
    572             "2026-04-17T14:00:00Z",
    573         );
    574 
    575         let projection = store
    576             .load_today_agenda(Some(farm_id))
    577             .expect("today agenda should load");
    578         let summary = projection.summary.expect("summary should exist");
    579         let farm = projection.farm.expect("farm should exist");
    580         let next_window = projection
    581             .next_fulfillment_window
    582             .expect("next window should exist");
    583 
    584         assert_eq!(farm.farm_id, farm_id);
    585         assert_eq!(farm.display_name, "Willow Farm");
    586         assert_eq!(summary.orders_needing_action, 5);
    587         assert_eq!(summary.low_stock_products, 2);
    588         assert_eq!(summary.draft_products, 5);
    589         assert_eq!(
    590             projection.orders_needing_action.len() as i64,
    591             TODAY_AGENDA_LIST_LIMIT
    592         );
    593         assert_eq!(projection.orders_needing_action[0].order_number, "R-104");
    594         assert_eq!(projection.low_stock_products.len(), 2);
    595         assert_eq!(projection.low_stock_products[0].title, "Carrots");
    596         assert_eq!(projection.low_stock_products[1].title, "Greens");
    597         assert_eq!(
    598             projection.draft_products.len() as i64,
    599             TODAY_AGENDA_LIST_LIMIT
    600         );
    601         assert_eq!(projection.draft_products[0].title, "Draft 4");
    602         assert_eq!(next_window.fulfillment_window_id, earliest_window_id);
    603         assert_eq!(next_window.starts_at, "2099-04-18T16:00:00Z");
    604         assert!(projection.setup_checklist.is_empty());
    605     }
    606 
    607     #[test]
    608     fn today_agenda_uses_primary_farm_and_builds_setup_checklist_for_incomplete_farm() {
    609         let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
    610         let connection = store.connection();
    611         let primary_farm_id = FarmId::new();
    612         let secondary_farm_id = FarmId::new();
    613 
    614         insert_farm(
    615             connection,
    616             primary_farm_id,
    617             "First Farm",
    618             "incomplete",
    619             "2026-04-17T08:00:00Z",
    620         );
    621         insert_farm(
    622             connection,
    623             secondary_farm_id,
    624             "Second Farm",
    625             "ready",
    626             "2026-04-18T08:00:00Z",
    627         );
    628         insert_product(
    629             connection,
    630             primary_farm_id,
    631             "Unpublished Lettuce",
    632             "draft",
    633             0,
    634             "2026-04-17T09:00:00Z",
    635         );
    636         insert_product(
    637             connection,
    638             secondary_farm_id,
    639             "Published Beets",
    640             "published",
    641             5,
    642             "2026-04-17T10:00:00Z",
    643         );
    644         insert_window(
    645             connection,
    646             FulfillmentWindowId::new(),
    647             secondary_farm_id,
    648             "2099-04-20T16:00:00Z",
    649             "2099-04-20T18:00:00Z",
    650         );
    651 
    652         let projection = store
    653             .load_today_agenda(None)
    654             .expect("default farm today agenda should load");
    655         let farm = projection.farm.expect("farm should exist");
    656 
    657         assert_eq!(farm.farm_id, primary_farm_id);
    658         assert_eq!(projection.summary.expect("summary").draft_products, 1);
    659         assert_eq!(projection.setup_checklist.len(), 2);
    660         assert_eq!(
    661             projection.setup_checklist[0].kind,
    662             TodaySetupTaskKind::AddFulfillmentWindow
    663         );
    664         assert!(!projection.setup_checklist[0].is_complete);
    665         assert_eq!(
    666             projection.setup_checklist[1].kind,
    667             TodaySetupTaskKind::PublishProduct
    668         );
    669         assert!(!projection.setup_checklist[1].is_complete);
    670         assert!(projection.next_fulfillment_window.is_none());
    671     }
    672 
    673     #[test]
    674     fn saved_farm_summary_round_trips_into_today_projection() {
    675         let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
    676         let farm = radroots_app_view::FarmSummary {
    677             farm_id: FarmId::new(),
    678             display_name: "North field farm".to_owned(),
    679             readiness: radroots_app_view::FarmReadiness::Incomplete,
    680         };
    681 
    682         store
    683             .save_farm_summary(&farm)
    684             .expect("farm summary should save");
    685 
    686         let projection = store
    687             .load_today_agenda(Some(farm.farm_id))
    688             .expect("today agenda should load");
    689 
    690         assert_eq!(projection.farm, Some(farm));
    691         assert_eq!(
    692             projection.summary.expect("summary").orders_needing_action,
    693             0
    694         );
    695         assert_eq!(projection.setup_checklist.len(), 2);
    696     }
    697 
    698     fn insert_farm(
    699         connection: &Connection,
    700         farm_id: FarmId,
    701         display_name: &str,
    702         readiness: &str,
    703         created_at: &str,
    704     ) {
    705         connection
    706             .execute(
    707                 "insert into farms (id, display_name, readiness, created_at, updated_at) \
    708                  values (?1, ?2, ?3, ?4, ?4)",
    709                 params![farm_id.to_string(), display_name, readiness, created_at],
    710             )
    711             .expect("farm insert should succeed");
    712     }
    713 
    714     fn insert_window(
    715         connection: &Connection,
    716         fulfillment_window_id: FulfillmentWindowId,
    717         farm_id: FarmId,
    718         starts_at: &str,
    719         ends_at: &str,
    720     ) {
    721         connection
    722             .execute(
    723                 "insert into fulfillment_windows (id, farm_id, starts_at, ends_at, capacity_limit, created_at, updated_at) \
    724                  values (?1, ?2, ?3, ?4, null, ?3, ?3)",
    725                 params![
    726                     fulfillment_window_id.to_string(),
    727                     farm_id.to_string(),
    728                     starts_at,
    729                     ends_at
    730                 ],
    731             )
    732             .expect("fulfillment window insert should succeed");
    733     }
    734 
    735     fn insert_product(
    736         connection: &Connection,
    737         farm_id: FarmId,
    738         title: &str,
    739         status: &str,
    740         stock_count: u32,
    741         updated_at: &str,
    742     ) -> ProductId {
    743         let product_id = ProductId::new();
    744 
    745         connection
    746             .execute(
    747                 "insert into products (id, farm_id, title, status, stock_count, updated_at) \
    748                  values (?1, ?2, ?3, ?4, ?5, ?6)",
    749                 params![
    750                     product_id.to_string(),
    751                     farm_id.to_string(),
    752                     title,
    753                     status,
    754                     stock_count,
    755                     updated_at
    756                 ],
    757             )
    758             .expect("product insert should succeed");
    759 
    760         product_id
    761     }
    762 
    763     fn insert_order(
    764         connection: &Connection,
    765         farm_id: FarmId,
    766         fulfillment_window_id: Option<FulfillmentWindowId>,
    767         order_number: &str,
    768         customer_display_name: &str,
    769         status: &str,
    770         updated_at: &str,
    771     ) {
    772         connection
    773             .execute(
    774                 "insert into orders (id, farm_id, fulfillment_window_id, order_number, customer_display_name, status, updated_at) \
    775                  values (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
    776                 params![
    777                     radroots_app_view::OrderId::new().to_string(),
    778                     farm_id.to_string(),
    779                     fulfillment_window_id.map(|id| id.to_string()),
    780                     order_number,
    781                     customer_display_name,
    782                     status,
    783                     updated_at
    784                 ],
    785             )
    786             .expect("order insert should succeed");
    787     }
    788 }