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 }