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 }