app

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

products.rs (39912B)


      1 use std::cmp::Ordering;
      2 
      3 use radroots_app_view::{
      4     FarmId, FulfillmentWindowId, ProductAttentionState, ProductAvailabilityState,
      5     ProductAvailabilitySummary, ProductEditorDraft, ProductId, ProductPricePresentation,
      6     ProductPublishBlocker, ProductStatus, ProductStockState, ProductStockSummary, ProductsFilter,
      7     ProductsListProjection, ProductsListRow, ProductsListSummary, ProductsSort,
      8 };
      9 use rusqlite::{Connection, OptionalExtension, params};
     10 
     11 use crate::AppSqliteError;
     12 
     13 const PRODUCTS_LOW_STOCK_THRESHOLD: u32 = 3;
     14 
     15 pub struct AppProductsRepository<'a> {
     16     connection: &'a Connection,
     17 }
     18 
     19 impl<'a> AppProductsRepository<'a> {
     20     pub const fn new(connection: &'a Connection) -> Self {
     21         Self { connection }
     22     }
     23 
     24     pub fn load_products(
     25         &self,
     26         farm_id: FarmId,
     27         search_query: &str,
     28         filter: ProductsFilter,
     29         sort: ProductsSort,
     30     ) -> Result<ProductsListProjection, AppSqliteError> {
     31         let now_utc = self.current_utc_timestamp()?;
     32         let mut records = self.load_product_records(farm_id)?;
     33         let summary = summarize_records(&records, &now_utc);
     34         let normalized_search = normalize_search_query(search_query);
     35 
     36         records.retain(|record| {
     37             record.matches_search(normalized_search.as_deref())
     38                 && record.matches_filter(filter, &now_utc)
     39         });
     40         sort_records(&mut records, sort, &now_utc);
     41 
     42         Ok(ProductsListProjection {
     43             summary,
     44             rows: records
     45                 .into_iter()
     46                 .map(|record| record.into_list_row(&now_utc))
     47                 .collect(),
     48         })
     49     }
     50 
     51     pub fn load_product_editor_draft(
     52         &self,
     53         product_id: ProductId,
     54     ) -> Result<Option<ProductEditorDraft>, AppSqliteError> {
     55         self.load_product_record_by_id(product_id)?
     56             .map(|record| Ok(record.into_editor_draft()))
     57             .transpose()
     58     }
     59 
     60     pub fn create_product_draft(&self, farm_id: FarmId) -> Result<ProductId, AppSqliteError> {
     61         let product_id = ProductId::new();
     62 
     63         self.connection
     64             .execute(
     65                 "insert into products (
     66                     id,
     67                     farm_id,
     68                     title,
     69                     subtitle,
     70                     category,
     71                     status,
     72                     unit_label,
     73                     price_minor_units,
     74                     price_currency,
     75                     stock_count,
     76                     availability_window_id,
     77                     updated_at
     78                  ) values (
     79                     ?1,
     80                     ?2,
     81                     '',
     82                     '',
     83                     '',
     84                     'draft',
     85                     '',
     86                     null,
     87                     'USD',
     88                     null,
     89                     null,
     90                     strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
     91                  )",
     92                 params![product_id.to_string(), farm_id.to_string()],
     93             )
     94             .map_err(|source| AppSqliteError::Query {
     95                 operation: "create product draft",
     96                 source,
     97             })?;
     98 
     99         Ok(product_id)
    100     }
    101 
    102     pub fn save_product_editor_draft(
    103         &self,
    104         product_id: ProductId,
    105         draft: &ProductEditorDraft,
    106     ) -> Result<bool, AppSqliteError> {
    107         let updated_rows = self
    108             .connection
    109             .execute(
    110                 "update products
    111                  set
    112                     title = ?2,
    113                     subtitle = ?3,
    114                     category = ?4,
    115                     status = ?5,
    116                     unit_label = ?6,
    117                     price_minor_units = ?7,
    118                     price_currency = ?8,
    119                     stock_count = ?9,
    120                     availability_window_id = ?10,
    121                     updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
    122                  where id = ?1",
    123                 params![
    124                     product_id.to_string(),
    125                     draft.title.as_str(),
    126                     draft.subtitle.as_str(),
    127                     draft.category.as_str(),
    128                     draft.status.storage_key(),
    129                     draft.unit_label.as_str(),
    130                     draft.price_minor_units,
    131                     normalize_currency_code(&draft.price_currency),
    132                     draft.stock_quantity,
    133                     draft.availability_window_id.map(|id| id.to_string()),
    134                 ],
    135             )
    136             .map_err(|source| AppSqliteError::Query {
    137                 operation: "save product editor draft",
    138                 source,
    139             })?;
    140 
    141         Ok(updated_rows > 0)
    142     }
    143 
    144     pub fn update_product_stock(
    145         &self,
    146         product_id: ProductId,
    147         stock_quantity: u32,
    148     ) -> Result<bool, AppSqliteError> {
    149         let updated_rows = self
    150             .connection
    151             .execute(
    152                 "update products
    153                  set
    154                     stock_count = ?2,
    155                     updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
    156                  where id = ?1",
    157                 params![product_id.to_string(), stock_quantity],
    158             )
    159             .map_err(|source| AppSqliteError::Query {
    160                 operation: "update product stock",
    161                 source,
    162             })?;
    163 
    164         Ok(updated_rows > 0)
    165     }
    166 
    167     pub fn evaluate_product_publish_blockers(
    168         &self,
    169         product_id: ProductId,
    170     ) -> Result<Option<Vec<ProductPublishBlocker>>, AppSqliteError> {
    171         self.load_product_record_by_id(product_id)?
    172             .map(|record| Ok(record.editor_draft_publish_blockers()))
    173             .transpose()
    174     }
    175 
    176     fn current_utc_timestamp(&self) -> Result<String, AppSqliteError> {
    177         self.connection
    178             .query_row("select strftime('%Y-%m-%dT%H:%M:%SZ', 'now')", [], |row| {
    179                 row.get(0)
    180             })
    181             .map_err(|source| AppSqliteError::Query {
    182                 operation: "load current utc timestamp",
    183                 source,
    184             })
    185     }
    186 
    187     fn load_product_records(&self, farm_id: FarmId) -> Result<Vec<ProductRecord>, AppSqliteError> {
    188         let mut statement = self
    189             .connection
    190             .prepare(
    191                 "select
    192                     p.id,
    193                     p.farm_id,
    194                     p.title,
    195                     p.subtitle,
    196                     p.category,
    197                     p.status,
    198                     p.unit_label,
    199                     p.price_minor_units,
    200                     p.price_currency,
    201                     p.stock_count,
    202                     p.availability_window_id,
    203                     fw.starts_at,
    204                     fw.ends_at,
    205                     p.updated_at
    206                  from products p
    207                  left join fulfillment_windows fw on fw.id = p.availability_window_id
    208                  where p.farm_id = ?1",
    209             )
    210             .map_err(|source| AppSqliteError::Query {
    211                 operation: "prepare products list query",
    212                 source,
    213             })?;
    214         let rows = statement
    215             .query_map(params![farm_id.to_string()], |row| {
    216                 Ok((
    217                     row.get::<_, String>(0)?,
    218                     row.get::<_, String>(1)?,
    219                     row.get::<_, String>(2)?,
    220                     row.get::<_, String>(3)?,
    221                     row.get::<_, String>(4)?,
    222                     row.get::<_, String>(5)?,
    223                     row.get::<_, String>(6)?,
    224                     row.get::<_, Option<u32>>(7)?,
    225                     row.get::<_, String>(8)?,
    226                     row.get::<_, Option<u32>>(9)?,
    227                     row.get::<_, Option<String>>(10)?,
    228                     row.get::<_, Option<String>>(11)?,
    229                     row.get::<_, Option<String>>(12)?,
    230                     row.get::<_, String>(13)?,
    231                 ))
    232             })
    233             .map_err(|source| AppSqliteError::Query {
    234                 operation: "query products list",
    235                 source,
    236             })?;
    237         let mut records = Vec::new();
    238 
    239         for row in rows {
    240             let (
    241                 product_id,
    242                 farm_id,
    243                 title,
    244                 subtitle,
    245                 category,
    246                 status,
    247                 unit_label,
    248                 price_minor_units,
    249                 price_currency,
    250                 stock_count,
    251                 availability_window_id,
    252                 availability_starts_at,
    253                 availability_ends_at,
    254                 updated_at,
    255             ) = row.map_err(|source| AppSqliteError::Query {
    256                 operation: "read products list",
    257                 source,
    258             })?;
    259 
    260             records.push(ProductRecord {
    261                 product_id: parse_typed_id("products.id", product_id)?,
    262                 farm_id: parse_typed_id("products.farm_id", farm_id)?,
    263                 title,
    264                 subtitle,
    265                 category,
    266                 status: parse_product_status("products.status", status)?,
    267                 unit_label,
    268                 price_minor_units,
    269                 price_currency,
    270                 stock_count,
    271                 availability_window_id: parse_optional_typed_id(
    272                     "products.availability_window_id",
    273                     availability_window_id,
    274                 )?,
    275                 availability_starts_at,
    276                 availability_ends_at,
    277                 updated_at,
    278             });
    279         }
    280 
    281         Ok(records)
    282     }
    283 
    284     fn load_product_record_by_id(
    285         &self,
    286         product_id: ProductId,
    287     ) -> Result<Option<ProductRecord>, AppSqliteError> {
    288         let row = self
    289             .connection
    290             .query_row(
    291                 "select
    292                     p.id,
    293                     p.farm_id,
    294                     p.title,
    295                     p.subtitle,
    296                     p.category,
    297                     p.status,
    298                     p.unit_label,
    299                     p.price_minor_units,
    300                     p.price_currency,
    301                     p.stock_count,
    302                     p.availability_window_id,
    303                     fw.starts_at,
    304                     fw.ends_at,
    305                     p.updated_at
    306                  from products p
    307                  left join fulfillment_windows fw on fw.id = p.availability_window_id
    308                  where p.id = ?1
    309                  limit 1",
    310                 params![product_id.to_string()],
    311                 |row| {
    312                     Ok((
    313                         row.get::<_, String>(0)?,
    314                         row.get::<_, String>(1)?,
    315                         row.get::<_, String>(2)?,
    316                         row.get::<_, String>(3)?,
    317                         row.get::<_, String>(4)?,
    318                         row.get::<_, String>(5)?,
    319                         row.get::<_, String>(6)?,
    320                         row.get::<_, Option<u32>>(7)?,
    321                         row.get::<_, String>(8)?,
    322                         row.get::<_, Option<u32>>(9)?,
    323                         row.get::<_, Option<String>>(10)?,
    324                         row.get::<_, Option<String>>(11)?,
    325                         row.get::<_, Option<String>>(12)?,
    326                         row.get::<_, String>(13)?,
    327                     ))
    328                 },
    329             )
    330             .optional()
    331             .map_err(|source| AppSqliteError::Query {
    332                 operation: "load product editor draft",
    333                 source,
    334             })?;
    335 
    336         row.map(
    337             |(
    338                 product_id,
    339                 farm_id,
    340                 title,
    341                 subtitle,
    342                 category,
    343                 status,
    344                 unit_label,
    345                 price_minor_units,
    346                 price_currency,
    347                 stock_count,
    348                 availability_window_id,
    349                 availability_starts_at,
    350                 availability_ends_at,
    351                 updated_at,
    352             )| {
    353                 Ok(ProductRecord {
    354                     product_id: parse_typed_id("products.id", product_id)?,
    355                     farm_id: parse_typed_id("products.farm_id", farm_id)?,
    356                     title,
    357                     subtitle,
    358                     category,
    359                     status: parse_product_status("products.status", status)?,
    360                     unit_label,
    361                     price_minor_units,
    362                     price_currency,
    363                     stock_count,
    364                     availability_window_id: parse_optional_typed_id(
    365                         "products.availability_window_id",
    366                         availability_window_id,
    367                     )?,
    368                     availability_starts_at,
    369                     availability_ends_at,
    370                     updated_at,
    371                 })
    372             },
    373         )
    374         .transpose()
    375     }
    376 }
    377 
    378 #[derive(Clone, Debug, Eq, PartialEq)]
    379 struct ProductRecord {
    380     product_id: ProductId,
    381     farm_id: FarmId,
    382     title: String,
    383     subtitle: String,
    384     category: String,
    385     status: ProductStatus,
    386     unit_label: String,
    387     price_minor_units: Option<u32>,
    388     price_currency: String,
    389     stock_count: Option<u32>,
    390     availability_window_id: Option<FulfillmentWindowId>,
    391     availability_starts_at: Option<String>,
    392     availability_ends_at: Option<String>,
    393     updated_at: String,
    394 }
    395 
    396 impl ProductRecord {
    397     fn into_list_row(self, now_utc: &str) -> ProductsListRow {
    398         let availability = self.availability_summary(now_utc);
    399         let attention_state = self.attention_state(now_utc, &availability);
    400         let stock = self.stock_summary();
    401         let price = self.price_presentation();
    402 
    403         ProductsListRow {
    404             product_id: self.product_id,
    405             farm_id: self.farm_id,
    406             title: self.title,
    407             subtitle: empty_string_to_none(self.subtitle),
    408             status: self.status,
    409             attention_state,
    410             availability,
    411             stock,
    412             price,
    413             updated_at: self.updated_at,
    414         }
    415     }
    416 
    417     fn into_editor_draft(self) -> ProductEditorDraft {
    418         ProductEditorDraft {
    419             title: self.title,
    420             subtitle: self.subtitle,
    421             category: self.category,
    422             unit_label: self.unit_label,
    423             price_minor_units: self.price_minor_units,
    424             price_currency: self.price_currency,
    425             stock_quantity: self.stock_count,
    426             availability_window_id: self.availability_window_id,
    427             status: self.status,
    428         }
    429     }
    430 
    431     fn matches_search(&self, search_query: Option<&str>) -> bool {
    432         let Some(search_query) = search_query else {
    433             return true;
    434         };
    435 
    436         self.title.to_lowercase().contains(search_query)
    437             || self.subtitle.to_lowercase().contains(search_query)
    438     }
    439 
    440     fn matches_filter(&self, filter: ProductsFilter, now_utc: &str) -> bool {
    441         match filter {
    442             ProductsFilter::All => true,
    443             ProductsFilter::Live => self.status == ProductStatus::Published,
    444             ProductsFilter::Drafts => self.status == ProductStatus::Draft,
    445             ProductsFilter::NeedAttention => self
    446                 .attention_state(now_utc, &self.availability_summary(now_utc))
    447                 .requires_attention(),
    448             ProductsFilter::Paused => self.status == ProductStatus::Paused,
    449             ProductsFilter::Archived => self.status == ProductStatus::Archived,
    450         }
    451     }
    452 
    453     fn availability_summary(&self, now_utc: &str) -> ProductAvailabilitySummary {
    454         match (&self.availability_starts_at, &self.availability_ends_at) {
    455             (Some(starts_at), Some(ends_at))
    456                 if starts_at.as_str() <= now_utc && ends_at.as_str() >= now_utc =>
    457             {
    458                 ProductAvailabilitySummary {
    459                     state: ProductAvailabilityState::Open,
    460                     label: "Pickup open".to_owned(),
    461                 }
    462             }
    463             (Some(starts_at), Some(ends_at)) if starts_at.as_str() > now_utc => {
    464                 ProductAvailabilitySummary {
    465                     state: ProductAvailabilityState::Scheduled,
    466                     label: format_window_label(starts_at, ends_at),
    467                 }
    468             }
    469             (Some(_), Some(_)) => ProductAvailabilitySummary {
    470                 state: ProductAvailabilityState::NoFutureWindow,
    471                 label: "No future slot".to_owned(),
    472             },
    473             _ => ProductAvailabilitySummary {
    474                 state: ProductAvailabilityState::MissingWindow,
    475                 label: "Missing window".to_owned(),
    476             },
    477         }
    478     }
    479 
    480     fn attention_state(
    481         &self,
    482         _now_utc: &str,
    483         availability: &ProductAvailabilitySummary,
    484     ) -> ProductAttentionState {
    485         if matches!(self.status, ProductStatus::Paused | ProductStatus::Archived) {
    486             return ProductAttentionState::Healthy;
    487         }
    488 
    489         let stock = self.stock_summary();
    490         if self.status == ProductStatus::Published {
    491             if stock.state == ProductStockState::SoldOut {
    492                 return ProductAttentionState::SoldOut;
    493             }
    494 
    495             if stock.state == ProductStockState::LowStock {
    496                 return ProductAttentionState::LowStock;
    497             }
    498         }
    499 
    500         match availability.state {
    501             ProductAvailabilityState::MissingWindow => {
    502                 return ProductAttentionState::MissingAvailability;
    503             }
    504             ProductAvailabilityState::NoFutureWindow => {
    505                 return ProductAttentionState::NoFutureAvailability;
    506             }
    507             ProductAvailabilityState::Scheduled | ProductAvailabilityState::Open => {}
    508         }
    509 
    510         if self
    511             .editor_draft_publish_blockers()
    512             .into_iter()
    513             .any(|blocker| blocker != ProductPublishBlocker::AttachAvailability)
    514         {
    515             return ProductAttentionState::MissingDetails;
    516         }
    517 
    518         ProductAttentionState::Healthy
    519     }
    520 
    521     fn stock_summary(&self) -> ProductStockSummary {
    522         let state = match self.stock_count {
    523             None => ProductStockState::Unset,
    524             Some(0) => ProductStockState::SoldOut,
    525             Some(quantity) if quantity <= PRODUCTS_LOW_STOCK_THRESHOLD => {
    526                 ProductStockState::LowStock
    527             }
    528             Some(_) => ProductStockState::InStock,
    529         };
    530 
    531         ProductStockSummary {
    532             quantity: self.stock_count,
    533             unit_label: empty_string_to_none(self.unit_label.clone()),
    534             state,
    535         }
    536     }
    537 
    538     fn price_presentation(&self) -> Option<ProductPricePresentation> {
    539         self.price_minor_units
    540             .filter(|amount| *amount > 0)
    541             .zip(empty_string_to_none(self.unit_label.clone()))
    542             .map(
    543                 |(amount_minor_units, unit_label)| ProductPricePresentation {
    544                     amount_minor_units,
    545                     currency_code: self.price_currency.clone(),
    546                     unit_label,
    547                 },
    548             )
    549     }
    550 
    551     fn editor_draft_publish_blockers(&self) -> Vec<ProductPublishBlocker> {
    552         let mut blockers = self.clone().into_editor_draft().publish_blockers();
    553         if self.availability_window_id.is_some()
    554             && (self.availability_starts_at.is_none() || self.availability_ends_at.is_none())
    555             && !blockers.contains(&ProductPublishBlocker::AttachAvailability)
    556         {
    557             blockers.push(ProductPublishBlocker::AttachAvailability);
    558         }
    559         blockers
    560     }
    561 }
    562 
    563 fn normalize_search_query(search_query: &str) -> Option<String> {
    564     let trimmed = search_query.trim();
    565     if trimmed.is_empty() {
    566         None
    567     } else {
    568         Some(trimmed.to_lowercase())
    569     }
    570 }
    571 
    572 fn summarize_records(records: &[ProductRecord], now_utc: &str) -> ProductsListSummary {
    573     ProductsListSummary {
    574         total_products: records.len() as u32,
    575         live_products: records
    576             .iter()
    577             .filter(|record| record.status == ProductStatus::Published)
    578             .count() as u32,
    579         draft_products: records
    580             .iter()
    581             .filter(|record| record.status == ProductStatus::Draft)
    582             .count() as u32,
    583         need_attention_products: records
    584             .iter()
    585             .filter(|record| {
    586                 record
    587                     .attention_state(now_utc, &record.availability_summary(now_utc))
    588                     .requires_attention()
    589             })
    590             .count() as u32,
    591     }
    592 }
    593 
    594 fn sort_records(records: &mut [ProductRecord], sort: ProductsSort, now_utc: &str) {
    595     records.sort_by(|left, right| match sort {
    596         ProductsSort::Updated => right
    597             .updated_at
    598             .cmp(&left.updated_at)
    599             .then_with(|| right.product_id.cmp(&left.product_id)),
    600         ProductsSort::Name => lower_cmp(&left.title, &right.title)
    601             .then_with(|| lower_cmp(&left.subtitle, &right.subtitle))
    602             .then_with(|| left.product_id.cmp(&right.product_id)),
    603         ProductsSort::Availability => availability_rank(left, now_utc)
    604             .cmp(&availability_rank(right, now_utc))
    605             .then_with(|| {
    606                 option_string_cmp(&left.availability_starts_at, &right.availability_starts_at)
    607             })
    608             .then_with(|| lower_cmp(&left.title, &right.title))
    609             .then_with(|| left.product_id.cmp(&right.product_id)),
    610         ProductsSort::Stock => stock_quantity_rank(left)
    611             .cmp(&stock_quantity_rank(right))
    612             .then_with(|| lower_cmp(&left.title, &right.title))
    613             .then_with(|| left.product_id.cmp(&right.product_id)),
    614         ProductsSort::Price => price_rank(left)
    615             .cmp(&price_rank(right))
    616             .then_with(|| lower_cmp(&left.title, &right.title))
    617             .then_with(|| left.product_id.cmp(&right.product_id)),
    618     });
    619 }
    620 
    621 fn availability_rank(record: &ProductRecord, now_utc: &str) -> (u8, Option<String>) {
    622     let availability = record.availability_summary(now_utc);
    623     let rank = match availability.state {
    624         ProductAvailabilityState::Open => 0,
    625         ProductAvailabilityState::Scheduled => 1,
    626         ProductAvailabilityState::MissingWindow => 2,
    627         ProductAvailabilityState::NoFutureWindow => 3,
    628     };
    629 
    630     (rank, record.availability_starts_at.clone())
    631 }
    632 
    633 fn stock_quantity_rank(record: &ProductRecord) -> (u8, u32) {
    634     match record.stock_count {
    635         Some(quantity) => (0, quantity),
    636         None => (1, u32::MAX),
    637     }
    638 }
    639 
    640 fn price_rank(record: &ProductRecord) -> (u8, u32) {
    641     match record.price_minor_units.filter(|amount| *amount > 0) {
    642         Some(amount_minor_units) => (0, amount_minor_units),
    643         None => (1, u32::MAX),
    644     }
    645 }
    646 
    647 fn lower_cmp(left: &str, right: &str) -> Ordering {
    648     left.to_lowercase().cmp(&right.to_lowercase())
    649 }
    650 
    651 fn option_string_cmp(left: &Option<String>, right: &Option<String>) -> Ordering {
    652     match (left.as_deref(), right.as_deref()) {
    653         (Some(left), Some(right)) => left.cmp(right),
    654         (Some(_), None) => Ordering::Less,
    655         (None, Some(_)) => Ordering::Greater,
    656         (None, None) => Ordering::Equal,
    657     }
    658 }
    659 
    660 fn empty_string_to_none(value: String) -> Option<String> {
    661     let trimmed = value.trim();
    662     if trimmed.is_empty() {
    663         None
    664     } else {
    665         Some(trimmed.to_owned())
    666     }
    667 }
    668 
    669 fn normalize_currency_code(value: &str) -> String {
    670     let trimmed = value.trim();
    671     if trimmed.is_empty() {
    672         "USD".to_owned()
    673     } else {
    674         trimmed.to_ascii_uppercase()
    675     }
    676 }
    677 
    678 fn format_window_label(starts_at: &str, ends_at: &str) -> String {
    679     let start_date = starts_at.get(0..10);
    680     let start_time = starts_at.get(11..16);
    681     let end_date = ends_at.get(0..10);
    682     let end_time = ends_at.get(11..16);
    683 
    684     match (start_date, start_time, end_date, end_time) {
    685         (Some(start_date), Some(start_time), Some(end_date), Some(end_time))
    686             if start_date == end_date =>
    687         {
    688             format!("{start_date} {start_time}-{end_time} UTC")
    689         }
    690         (Some(start_date), Some(start_time), Some(end_date), Some(end_time)) => {
    691             format!("{start_date} {start_time} UTC to {end_date} {end_time} UTC")
    692         }
    693         _ => starts_at.to_owned(),
    694     }
    695 }
    696 
    697 fn parse_typed_id<T>(field: &'static str, value: String) -> Result<T, AppSqliteError>
    698 where
    699     T: std::str::FromStr,
    700 {
    701     value
    702         .parse()
    703         .map_err(|_| AppSqliteError::DecodeId { field, value })
    704 }
    705 
    706 fn parse_optional_typed_id<T>(
    707     field: &'static str,
    708     value: Option<String>,
    709 ) -> Result<Option<T>, AppSqliteError>
    710 where
    711     T: std::str::FromStr,
    712 {
    713     value.map(|value| parse_typed_id(field, value)).transpose()
    714 }
    715 
    716 fn parse_product_status(
    717     field: &'static str,
    718     value: String,
    719 ) -> Result<ProductStatus, AppSqliteError> {
    720     match value.as_str() {
    721         "draft" => Ok(ProductStatus::Draft),
    722         "published" => Ok(ProductStatus::Published),
    723         "paused" => Ok(ProductStatus::Paused),
    724         "archived" => Ok(ProductStatus::Archived),
    725         _ => Err(AppSqliteError::DecodeEnum { field, value }),
    726     }
    727 }
    728 
    729 #[cfg(test)]
    730 mod tests {
    731     use radroots_app_view::{
    732         FarmId, FulfillmentWindowId, ProductAttentionState, ProductAvailabilityState,
    733         ProductEditorDraft, ProductId, ProductPublishBlocker, ProductStatus, ProductStockState,
    734         ProductsFilter, ProductsSort,
    735     };
    736     use rusqlite::{Connection, params};
    737 
    738     use crate::{AppSqliteStore, DatabaseTarget};
    739 
    740     use super::AppProductsRepository;
    741 
    742     #[test]
    743     fn products_list_projection_is_typed_and_supports_search_filter_and_sort() {
    744         let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
    745         let connection = store.connection();
    746         let repository = AppProductsRepository::new(connection);
    747         let farm_id = insert_farm(connection, "North Meadow Farm");
    748         let future_window_id = insert_window(
    749             connection,
    750             farm_id,
    751             "2099-04-18T16:00:00Z",
    752             "2099-04-18T18:00:00Z",
    753         );
    754 
    755         insert_product(
    756             connection,
    757             farm_id,
    758             SeedProduct {
    759                 title: "Salad mix",
    760                 subtitle: "Spring blend",
    761                 category: "greens",
    762                 status: "published",
    763                 unit_label: "box",
    764                 price_minor_units: Some(600),
    765                 stock_count: Some(2),
    766                 availability_window_id: Some(future_window_id),
    767                 updated_at: "2026-04-18T10:00:00Z",
    768             },
    769         );
    770         insert_product(
    771             connection,
    772             farm_id,
    773             SeedProduct {
    774                 title: "Pea shoots",
    775                 subtitle: "Tray-grown",
    776                 category: "",
    777                 status: "draft",
    778                 unit_label: "bag",
    779                 price_minor_units: Some(300),
    780                 stock_count: None,
    781                 availability_window_id: None,
    782                 updated_at: "2026-04-18T09:00:00Z",
    783             },
    784         );
    785         insert_product(
    786             connection,
    787             farm_id,
    788             SeedProduct {
    789                 title: "Heirloom tomatoes",
    790                 subtitle: "Brandywine",
    791                 category: "vegetables",
    792                 status: "published",
    793                 unit_label: "lb",
    794                 price_minor_units: Some(450),
    795                 stock_count: Some(12),
    796                 availability_window_id: Some(future_window_id),
    797                 updated_at: "2026-04-18T08:00:00Z",
    798             },
    799         );
    800         insert_product(
    801             connection,
    802             farm_id,
    803             SeedProduct {
    804                 title: "Carrot bunches",
    805                 subtitle: "Nantes",
    806                 category: "vegetables",
    807                 status: "paused",
    808                 unit_label: "each",
    809                 price_minor_units: Some(400),
    810                 stock_count: Some(7),
    811                 availability_window_id: None,
    812                 updated_at: "2026-04-18T07:00:00Z",
    813             },
    814         );
    815         insert_product(
    816             connection,
    817             farm_id,
    818             SeedProduct {
    819                 title: "Old beets",
    820                 subtitle: "",
    821                 category: "vegetables",
    822                 status: "archived",
    823                 unit_label: "bunch",
    824                 price_minor_units: Some(250),
    825                 stock_count: Some(4),
    826                 availability_window_id: None,
    827                 updated_at: "2026-04-18T06:00:00Z",
    828             },
    829         );
    830 
    831         let all_products = repository
    832             .load_products(farm_id, "", ProductsFilter::All, ProductsSort::Updated)
    833             .expect("products list should load");
    834         let attention_products = repository
    835             .load_products(
    836                 farm_id,
    837                 "",
    838                 ProductsFilter::NeedAttention,
    839                 ProductsSort::Name,
    840             )
    841             .expect("attention products should load");
    842         let searched_products = repository
    843             .load_products(farm_id, "pea", ProductsFilter::All, ProductsSort::Name)
    844             .expect("searched products should load");
    845 
    846         assert_eq!(all_products.summary.total_products, 5);
    847         assert_eq!(all_products.summary.live_products, 2);
    848         assert_eq!(all_products.summary.draft_products, 1);
    849         assert_eq!(all_products.summary.need_attention_products, 2);
    850         assert_eq!(all_products.rows[0].title, "Salad mix");
    851         assert_eq!(all_products.rows[1].title, "Pea shoots");
    852         assert_eq!(
    853             all_products.rows[0].attention_state,
    854             ProductAttentionState::LowStock
    855         );
    856         assert_eq!(
    857             all_products.rows[1].attention_state,
    858             ProductAttentionState::MissingAvailability
    859         );
    860         assert_eq!(
    861             all_products.rows[2].availability.state,
    862             ProductAvailabilityState::Scheduled
    863         );
    864         assert_eq!(
    865             attention_products
    866                 .rows
    867                 .iter()
    868                 .map(|row| row.title.as_str())
    869                 .collect::<Vec<_>>(),
    870             vec!["Pea shoots", "Salad mix"]
    871         );
    872         assert_eq!(searched_products.rows.len(), 1);
    873         assert_eq!(searched_products.rows[0].title, "Pea shoots");
    874         assert_eq!(
    875             searched_products.rows[0].subtitle.as_deref(),
    876             Some("Tray-grown")
    877         );
    878     }
    879 
    880     #[test]
    881     fn product_editor_draft_round_trips_through_sqlite() {
    882         let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
    883         let connection = store.connection();
    884         let repository = AppProductsRepository::new(connection);
    885         let farm_id = insert_farm(connection, "Willow Farm");
    886         let window_id = insert_window(
    887             connection,
    888             farm_id,
    889             "2099-04-20T16:00:00Z",
    890             "2099-04-20T18:00:00Z",
    891         );
    892         let product_id = repository
    893             .create_product_draft(farm_id)
    894             .expect("draft product should create");
    895         let saved_draft = ProductEditorDraft {
    896             title: "Heirloom tomatoes".to_owned(),
    897             subtitle: "Brandywine".to_owned(),
    898             category: "vegetables".to_owned(),
    899             unit_label: "lb".to_owned(),
    900             price_minor_units: Some(450),
    901             price_currency: "usd".to_owned(),
    902             stock_quantity: Some(12),
    903             availability_window_id: Some(window_id),
    904             status: ProductStatus::Published,
    905         };
    906 
    907         let created_draft = repository
    908             .load_product_editor_draft(product_id)
    909             .expect("editor draft should load")
    910             .expect("created product should exist");
    911 
    912         assert_eq!(created_draft, ProductEditorDraft::default());
    913         assert!(
    914             repository
    915                 .save_product_editor_draft(product_id, &saved_draft)
    916                 .expect("editor draft should save")
    917         );
    918 
    919         let reloaded_draft = repository
    920             .load_product_editor_draft(product_id)
    921             .expect("reloaded draft should load")
    922             .expect("saved product should exist");
    923 
    924         assert_eq!(
    925             reloaded_draft,
    926             ProductEditorDraft {
    927                 price_currency: "USD".to_owned(),
    928                 ..saved_draft
    929             }
    930         );
    931     }
    932 
    933     #[test]
    934     fn stock_updates_and_publish_blockers_are_truthful() {
    935         let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
    936         let connection = store.connection();
    937         let repository = AppProductsRepository::new(connection);
    938         let farm_id = insert_farm(connection, "Oak Farm");
    939         let window_id = insert_window(
    940             connection,
    941             farm_id,
    942             "2099-04-22T16:00:00Z",
    943             "2099-04-22T18:00:00Z",
    944         );
    945         let product_id = repository
    946             .create_product_draft(farm_id)
    947             .expect("draft product should create");
    948 
    949         assert_eq!(
    950             repository
    951                 .evaluate_product_publish_blockers(product_id)
    952                 .expect("blockers should load"),
    953             Some(vec![
    954                 ProductPublishBlocker::AddProductName,
    955                 ProductPublishBlocker::ChooseCategory,
    956                 ProductPublishBlocker::ChooseUnit,
    957                 ProductPublishBlocker::SetPrice,
    958                 ProductPublishBlocker::SetStock,
    959                 ProductPublishBlocker::AttachAvailability,
    960             ])
    961         );
    962 
    963         assert!(
    964             repository
    965                 .save_product_editor_draft(
    966                     product_id,
    967                     &ProductEditorDraft {
    968                         title: "Salad mix".to_owned(),
    969                         subtitle: "Spring blend".to_owned(),
    970                         category: "greens".to_owned(),
    971                         unit_label: "box".to_owned(),
    972                         price_minor_units: Some(600),
    973                         price_currency: "USD".to_owned(),
    974                         stock_quantity: Some(12),
    975                         availability_window_id: Some(window_id),
    976                         status: ProductStatus::Published,
    977                     },
    978                 )
    979                 .expect("ready draft should save")
    980         );
    981         assert_eq!(
    982             repository
    983                 .evaluate_product_publish_blockers(product_id)
    984                 .expect("ready blockers should load"),
    985             Some(Vec::new())
    986         );
    987         let stale_window_id = FulfillmentWindowId::new();
    988         connection
    989             .execute_batch("PRAGMA foreign_keys = OFF;")
    990             .expect("foreign keys should disable for stale fixture");
    991         connection
    992             .execute(
    993                 "update products set availability_window_id = ?2 where id = ?1",
    994                 params![product_id.to_string(), stale_window_id.to_string()],
    995             )
    996             .expect("stale availability id should write");
    997         connection
    998             .execute_batch("PRAGMA foreign_keys = ON;")
    999             .expect("foreign keys should restore");
   1000         assert_eq!(
   1001             repository
   1002                 .evaluate_product_publish_blockers(product_id)
   1003                 .expect("stale blockers should load"),
   1004             Some(vec![ProductPublishBlocker::AttachAvailability])
   1005         );
   1006         connection
   1007             .execute_batch("PRAGMA foreign_keys = OFF;")
   1008             .expect("foreign keys should disable for fixture restore");
   1009         connection
   1010             .execute(
   1011                 "update products set availability_window_id = ?2 where id = ?1",
   1012                 params![product_id.to_string(), window_id.to_string()],
   1013             )
   1014             .expect("ready availability id should restore");
   1015         connection
   1016             .execute_batch("PRAGMA foreign_keys = ON;")
   1017             .expect("foreign keys should restore");
   1018 
   1019         assert!(
   1020             repository
   1021                 .update_product_stock(product_id, 0)
   1022                 .expect("stock should update")
   1023         );
   1024         let sold_out_row = repository
   1025             .load_products(farm_id, "", ProductsFilter::All, ProductsSort::Updated)
   1026             .expect("products should load")
   1027             .rows
   1028             .into_iter()
   1029             .find(|row| row.product_id == product_id)
   1030             .expect("saved product row should exist");
   1031 
   1032         assert_eq!(sold_out_row.stock.quantity, Some(0));
   1033         assert_eq!(sold_out_row.stock.state, ProductStockState::SoldOut);
   1034         assert_eq!(sold_out_row.attention_state, ProductAttentionState::SoldOut);
   1035 
   1036         assert!(
   1037             repository
   1038                 .update_product_stock(product_id, 8)
   1039                 .expect("stock should update again")
   1040         );
   1041         let restocked_row = repository
   1042             .load_products(farm_id, "", ProductsFilter::All, ProductsSort::Updated)
   1043             .expect("restocked products should load")
   1044             .rows
   1045             .into_iter()
   1046             .find(|row| row.product_id == product_id)
   1047             .expect("restocked product row should exist");
   1048 
   1049         assert_eq!(restocked_row.stock.quantity, Some(8));
   1050         assert_eq!(restocked_row.stock.state, ProductStockState::InStock);
   1051         assert_eq!(
   1052             restocked_row.attention_state,
   1053             ProductAttentionState::Healthy
   1054         );
   1055     }
   1056 
   1057     fn insert_farm(connection: &Connection, display_name: &str) -> FarmId {
   1058         let farm_id = FarmId::new();
   1059 
   1060         connection
   1061             .execute(
   1062                 "insert into farms (id, display_name, readiness, created_at, updated_at)
   1063                  values (?1, ?2, 'ready', '2026-04-18T08:00:00Z', '2026-04-18T08:00:00Z')",
   1064                 params![farm_id.to_string(), display_name],
   1065             )
   1066             .expect("farm insert should succeed");
   1067 
   1068         farm_id
   1069     }
   1070 
   1071     fn insert_window(
   1072         connection: &Connection,
   1073         farm_id: FarmId,
   1074         starts_at: &str,
   1075         ends_at: &str,
   1076     ) -> FulfillmentWindowId {
   1077         let window_id = FulfillmentWindowId::new();
   1078 
   1079         connection
   1080             .execute(
   1081                 "insert into fulfillment_windows (id, farm_id, starts_at, ends_at, capacity_limit, created_at, updated_at)
   1082                  values (?1, ?2, ?3, ?4, null, ?3, ?3)",
   1083                 params![window_id.to_string(), farm_id.to_string(), starts_at, ends_at],
   1084             )
   1085             .expect("fulfillment window insert should succeed");
   1086 
   1087         window_id
   1088     }
   1089 
   1090     fn insert_product(
   1091         connection: &Connection,
   1092         farm_id: FarmId,
   1093         seed: SeedProduct<'_>,
   1094     ) -> ProductId {
   1095         let product_id = ProductId::new();
   1096 
   1097         connection
   1098             .execute(
   1099                 "insert into products (
   1100                     id,
   1101                     farm_id,
   1102                     title,
   1103                     subtitle,
   1104                     category,
   1105                     status,
   1106                     unit_label,
   1107                     price_minor_units,
   1108                     price_currency,
   1109                     stock_count,
   1110                     availability_window_id,
   1111                     updated_at
   1112                  ) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'USD', ?9, ?10, ?11)",
   1113                 params![
   1114                     product_id.to_string(),
   1115                     farm_id.to_string(),
   1116                     seed.title,
   1117                     seed.subtitle,
   1118                     seed.category,
   1119                     seed.status,
   1120                     seed.unit_label,
   1121                     seed.price_minor_units,
   1122                     seed.stock_count,
   1123                     seed.availability_window_id.map(|id| id.to_string()),
   1124                     seed.updated_at,
   1125                 ],
   1126             )
   1127             .expect("product insert should succeed");
   1128 
   1129         product_id
   1130     }
   1131 
   1132     struct SeedProduct<'a> {
   1133         title: &'a str,
   1134         subtitle: &'a str,
   1135         category: &'a str,
   1136         status: &'a str,
   1137         unit_label: &'a str,
   1138         price_minor_units: Option<u32>,
   1139         stock_count: Option<u32>,
   1140         availability_window_id: Option<FulfillmentWindowId>,
   1141         updated_at: &'a str,
   1142     }
   1143 }