app

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

commit 0d3893f5ea8dad9b9b2280570091ec96171fc6da
parent 5de72ac3c622bb684d2ed205b9f204705cfcb19f
Author: triesap <tyson@radroots.org>
Date:   Tue, 21 Apr 2026 02:19:19 +0000

buyer: add repeat-demand reorder flow

- derive buyer reorder eligibility from current marketplace listings
- add runtime actions to rebuild the cart from eligible prior items
- reuse cart replacement confirmation inside buyer order detail
- cover reorder projections and partial availability with tests

Diffstat:
Mcrates/launchers/desktop/src/runtime.rs | 210++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/launchers/desktop/src/source_guards.rs | 6++++++
Mcrates/launchers/desktop/src/window.rs | 126++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/shared/i18n/src/keys.rs | 1+
Mcrates/shared/i18n/src/lib.rs | 4++++
Mcrates/shared/sqlite/src/buyer.rs | 551++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/shared/sqlite/src/lib.rs | 12+++++++++++-
Mi18n/locales/en/messages.json | 1+
8 files changed, 890 insertions(+), 21 deletions(-)

diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -27,7 +27,8 @@ use radroots_app_remote_signer::{ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingSession, }; use radroots_app_sqlite::{ - APP_ACTIVITY_CONTEXT_LIMIT, AppSqliteError, AppSqliteStore, DatabaseTarget, + APP_ACTIVITY_CONTEXT_LIMIT, AppSqliteError, AppSqliteStore, BuyerRepeatDemandApplyOutcome, + DatabaseTarget, StoredPendingSyncOperation, StoredSyncConflict, derive_farm_rules_readiness, }; use radroots_app_state::{ @@ -297,6 +298,15 @@ impl DesktopAppRuntime { self.lock_state_mut().open_personal_order_detail(order_id) } + pub fn repeat_personal_order( + &self, + order_id: OrderId, + replace_existing: bool, + ) -> Result<bool, AppSqliteError> { + self.lock_state_mut() + .repeat_personal_order(order_id, replace_existing) + } + pub fn set_personal_search_query(&self, search_query: &str) -> Result<bool, AppSqliteError> { self.lock_state_mut() .set_personal_search_query(search_query) @@ -1337,6 +1347,67 @@ impl DesktopAppRuntimeState { Ok(detail_changed || section_changed) } + fn repeat_personal_order( + &mut self, + order_id: OrderId, + replace_existing: bool, + ) -> Result<bool, AppSqliteError> { + let Some(sqlite_store) = self.sqlite_store.as_ref() else { + return Ok(false); + }; + let buyer_context = self.state_store.identity_projection().buyer_context(); + + match sqlite_store.apply_buyer_repeat_demand_to_cart( + &buyer_context, + order_id, + replace_existing, + )? { + BuyerRepeatDemandApplyOutcome::Applied => { + let refreshed_cart = sqlite_store.load_buyer_cart(&buyer_context)?; + let refreshed_checkout = sqlite_store.load_buyer_checkout(&buyer_context)?; + let refreshed_orders = sqlite_store.load_buyer_orders(&buyer_context)?; + let refreshed_detail = + sqlite_store.load_buyer_order_detail(&buyer_context, order_id)?; + let personal_changed = self.mutate_personal_projection(|projection| { + let mut changed = false; + if projection.cart.cart != refreshed_cart { + projection.cart.cart = refreshed_cart.clone(); + changed = true; + } + if projection.cart.checkout != refreshed_checkout { + projection.cart.checkout = refreshed_checkout.clone(); + changed = true; + } + if projection.orders.list != refreshed_orders { + projection.orders.list = refreshed_orders.clone(); + changed = true; + } + if projection.orders.detail != refreshed_detail { + projection.orders.detail = refreshed_detail.clone(); + changed = true; + } + + changed + }); + let section_changed = self.select_personal_section(PersonalSection::Cart); + + Ok(personal_changed || section_changed) + } + BuyerRepeatDemandApplyOutcome::ConfirmationRequired(replace_confirmation) => { + Ok(self.mutate_personal_projection(|projection| { + let cart = &mut projection.cart.cart; + if cart.replace_confirmation.as_ref() == Some(&replace_confirmation) { + return false; + } + + cart.replace_confirmation = Some(replace_confirmation); + true + })) + } + BuyerRepeatDemandApplyOutcome::Unavailable => Ok(false), + } + } + fn set_personal_search_query(&mut self, search_query: &str) -> Result<bool, AppSqliteError> { let query = self.state_store.personal_projection().search.query.clone(); if query.search_query == search_query { @@ -6080,6 +6151,143 @@ mod tests { } #[test] + fn runtime_repeat_personal_order_readds_only_currently_eligible_items() { + let runtime = memory_runtime(); + let (account_id, farm_id) = provision_ready_farmer_account(&runtime); + assert!( + runtime + .select_active_surface(ActiveSurface::Personal) + .expect("surface should switch into marketplace") + ); + let fulfillment_window_id = seed_buyer_marketplace_support( + &runtime, + account_id.as_str(), + farm_id, + "North field farm", + "Friday pickup", + ); + let available_product_id = seed_product( + &runtime, + farm_id, + "Salad mix", + "Spring blend", + "published", + Some(8), + "2026-04-20T09:00:00Z", + ); + let unavailable_product_id = seed_product( + &runtime, + farm_id, + "Pea shoots", + "Tray-grown", + "published", + Some(6), + "2026-04-20T10:00:00Z", + ); + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .connection() + .execute_batch(&format!( + "update products + set availability_window_id = '{fulfillment_window_id}' + where id in ('{available_product_id}', '{unavailable_product_id}')" + )) + .expect("buyer detail products should attach a fulfillment window"); + assert!( + runtime + .open_personal_product_detail(PersonalSection::Browse, available_product_id) + .expect("available buyer detail should open") + ); + assert!( + runtime + .add_personal_product_to_cart(PersonalSection::Browse, false) + .expect("available buyer product should add to cart") + ); + assert!( + runtime + .open_personal_product_detail(PersonalSection::Browse, unavailable_product_id) + .expect("unavailable buyer detail should open") + ); + assert!( + runtime + .add_personal_product_to_cart(PersonalSection::Browse, false) + .expect("second buyer product should add to cart") + ); + assert!( + runtime + .save_personal_checkout_draft(BuyerCheckoutDraft { + name: "Casey Buyer".to_owned(), + email: "casey@example.com".to_owned(), + phone: String::new(), + order_note: String::new(), + }) + .expect("buyer checkout draft should save") + ); + assert!( + runtime + .place_personal_order() + .expect("buyer order should place") + ); + let order_id = runtime.summary().personal_projection.orders.list.rows[0].order_id; + + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .connection() + .execute( + "update products set status = 'archived' where id = ?1", + [unavailable_product_id.to_string()], + ) + .expect("product should archive"); + + assert!( + runtime + .open_personal_order_detail(order_id) + .expect("buyer order detail should reopen") + ); + let detail_summary = runtime.summary(); + let repeat_demand = detail_summary + .personal_projection + .orders + .detail + .as_ref() + .and_then(|detail| detail.repeat_demand.as_ref()) + .expect("repeat demand should derive from buyer order detail"); + assert_eq!(repeat_demand.eligibility.storage_key(), "partial"); + assert_eq!(repeat_demand.available_item_count, 1); + assert_eq!(repeat_demand.unavailable_item_count, 1); + + assert!( + runtime + .repeat_personal_order(order_id, false) + .expect("repeat demand should add available items to cart") + ); + + let summary = runtime.summary(); + assert_eq!( + summary.shell_projection.selected_section, + ShellSection::Personal(PersonalSection::Cart) + ); + assert_eq!(summary.personal_projection.cart.cart.lines.len(), 1); + assert_eq!( + summary.personal_projection.cart.cart.lines[0].product_id, + available_product_id + ); + assert_eq!(summary.personal_projection.cart.cart.lines[0].quantity, 1); + assert!(summary + .personal_projection + .cart + .cart + .replace_confirmation + .is_none()); + } + + #[test] fn runtime_products_queries_refresh_the_repository_backed_projection() { let runtime = memory_runtime(); diff --git a/crates/launchers/desktop/src/source_guards.rs b/crates/launchers/desktop/src/source_guards.rs @@ -49,12 +49,16 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "buyer-checkout-back", "buyer-checkout-place-order", "buyer-listing-open", + "buyer-order-confirm-replace", + "buyer-order-keep-current", + "buyer-order-repeat-demand", "buyer.add_to_cart_failed", "buyer.cart_remove_failed", "buyer.checkout_place_failed", "buyer.checkout_save_failed", "buyer.detail_open_failed", "buyer.order_open_failed", + "buyer.repeat_demand_failed", "bunker uri", "bunker://466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f27?relay=wss%3A%2F%2Frelay.radroots.example", "buyer.fulfillment_filter_update_failed", @@ -63,6 +67,7 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "failed to open buyer order detail", "failed to place buyer order", "failed to remove buyer cart line", + "failed to reorder buyer order", "failed to save buyer checkout draft", "failed to open buyer product detail", "failed to update buyer fulfillment filter", @@ -321,6 +326,7 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::PersonalOrdersDetailFulfillmentLabel", "AppTextKey::PersonalOrdersDetailNoteLabel", "AppTextKey::PersonalOrdersDetailItemsTitle", + "AppTextKey::PersonalOrdersRepeatDemandTitle", "AppTextKey::PersonalOrdersStatusPlaced", "AppTextKey::PersonalOrdersStatusScheduled", "AppTextKey::PersonalOrdersStatusReady", diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -25,7 +25,8 @@ use radroots_app_models::{ ProductListRow, ProductPricePresentation, ProductPublishBlocker, ProductStatus, ProductsFilter, ProductsListRow, ProductsSort, RecoveryKind, RecoveryState, ReminderDeadlineProjection, ReminderDeliveryState, ReminderId, ReminderLogEntryProjection, ReminderLogProjection, - ReminderSurface, ReminderUrgency, ShellSection, TodayAgendaProjection, TodaySetupTaskKind, + ReminderSurface, ReminderUrgency, RepeatDemandEligibility, ShellSection, + TodayAgendaProjection, TodaySetupTaskKind, }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingPollOutcome, @@ -1285,6 +1286,27 @@ impl HomeView { } } + fn repeat_personal_order( + &mut self, + order_id: OrderId, + replace_existing: bool, + cx: &mut Context<Self>, + ) { + match self.runtime.repeat_personal_order(order_id, replace_existing) { + Ok(true) => cx.notify(), + Ok(false) => {} + Err(runtime_error) => { + error!( + target: "buyer", + event = "buyer.repeat_demand_failed", + error = %runtime_error, + order_id = %order_id, + "failed to reorder buyer order" + ); + } + } + } + fn select_products_filter(&mut self, filter: ProductsFilter, cx: &mut Context<Self>) { match self.runtime.select_products_filter(filter) { Ok(true) => { @@ -2655,7 +2677,18 @@ impl HomeView { orders .detail .as_ref() - .map(buyer_order_detail_card) + .map(|detail| { + buyer_order_detail_card( + detail, + runtime + .personal_projection + .cart + .cart + .replace_confirmation + .as_ref(), + cx, + ) + }) .unwrap_or_else(|| buyer_order_detail_empty_card().into_any_element()), ) .into_any_element() @@ -7779,7 +7812,15 @@ fn buyer_orders_list_entry( .into_any_element() } -fn buyer_order_detail_card(detail: &BuyerOrderDetailProjection) -> AnyElement { +fn buyer_order_detail_card( + detail: &BuyerOrderDetailProjection, + replace_confirmation: Option<&BuyerCartReplaceConfirmationProjection>, + cx: &mut Context<HomeView>, +) -> AnyElement { + let repeat_confirmation = + replace_confirmation.filter(|confirmation| confirmation.incoming_farm_display_name + == detail.farm_display_name); + home_card( app_shared_text(AppTextKey::PersonalOrdersDetailTitle), app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) @@ -7821,7 +7862,84 @@ fn buyer_order_detail_card(detail: &BuyerOrderDetailProjection) -> AnyElement { .when(detail.items.is_empty(), |this| { this.child(home_body_text(app_shared_text(AppTextKey::ValueNone))) }), - )), + )) + .when_some(detail.repeat_demand.as_ref(), |this, repeat_demand| { + this.child(app_form_section( + app_shared_text(AppTextKey::PersonalOrdersRepeatDemandTitle), + app_stack_v(APP_UI_THEME.foundation.spacing.small_px) + .w_full() + .when_some(repeat_demand.note.clone(), |this, note| { + this.child(home_body_text(note)) + }) + .when_some(repeat_confirmation, |this, replace_confirmation| { + this.child(app_surface_panel( + app_stack_v(APP_UI_THEME.foundation.spacing.small_px) + .w_full() + .p(px(APP_UI_THEME.shells.home_card_padding_px)) + .child(app_text_label(app_shared_text( + AppTextKey::PersonalDetailReplaceCartTitle, + ))) + .child(home_body_text(format!( + "{} {} {}.", + replace_confirmation.current_farm_display_name, + app_shared_text( + AppTextKey::PersonalDetailReplaceCartBody, + ), + replace_confirmation.incoming_farm_display_name, + ))) + .child( + app_cluster(APP_UI_THEME.foundation.spacing.small_px) + .w_full() + .child(action_button_primary( + "buyer-order-confirm-replace", + app_shared_text( + AppTextKey::PersonalDetailReplaceCartAction, + ), + cx.listener({ + let order_id = detail.order_id; + move |this, _, _, cx| { + this.repeat_personal_order( + order_id, true, cx, + ) + } + }), + cx, + )) + .child(action_button_compact( + "buyer-order-keep-current", + app_shared_text( + AppTextKey::PersonalDetailKeepCurrentCartAction, + ), + cx.listener(|this, _, _, cx| { + this.clear_personal_cart_replace_confirmation( + cx, + ) + }), + cx, + )), + ), + )) + }) + .when( + repeat_confirmation.is_none() + && repeat_demand.eligibility + != RepeatDemandEligibility::Unavailable, + |this| { + this.child(action_button_primary( + "buyer-order-repeat-demand", + SharedString::from(repeat_demand.action_label.clone()), + cx.listener({ + let order_id = detail.order_id; + move |this, _, _, cx| { + this.repeat_personal_order(order_id, false, cx) + } + }), + cx, + )) + }, + ), + )) + }), ) .into_any_element() } diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs @@ -120,6 +120,7 @@ define_app_text_keys! { PersonalOrdersDetailFulfillmentLabel => "personal.orders.detail.fulfillment.label", PersonalOrdersDetailNoteLabel => "personal.orders.detail.note.label", PersonalOrdersDetailItemsTitle => "personal.orders.detail.items.title", + PersonalOrdersRepeatDemandTitle => "personal.orders.repeat_demand.title", PersonalOrdersStatusPlaced => "personal.orders.status.placed", PersonalOrdersStatusScheduled => "personal.orders.status.scheduled", PersonalOrdersStatusReady => "personal.orders.status.ready", diff --git a/crates/shared/i18n/src/lib.rs b/crates/shared/i18n/src/lib.rs @@ -400,6 +400,10 @@ mod tests { app_text(AppTextKey::PersonalOrdersDetailNoteLabel), "Order note" ); + assert_eq!( + app_text(AppTextKey::PersonalOrdersRepeatDemandTitle), + "Reorder" + ); } #[test] diff --git a/crates/shared/sqlite/src/buyer.rs b/crates/shared/sqlite/src/buyer.rs @@ -1,12 +1,14 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use radroots_app_models::{ - BuyerCartLineProjection, BuyerCartProjection, BuyerCheckoutDraft, BuyerCheckoutProjection, - BuyerCheckoutSummaryProjection, BuyerContext, BuyerListingRow, BuyerListingsProjection, - BuyerOrderDetailProjection, BuyerOrderStatus, BuyerOrdersListRow, BuyerOrdersProjection, - BuyerProductDetailProjection, FarmId, FarmOrderMethod, FulfillmentWindowId, OrderDetailItemRow, - OrderId, OrderStatus, ProductAvailabilityState, ProductAvailabilitySummary, ProductId, - ProductPricePresentation, ProductStatus, ProductStockState, ProductStockSummary, + BuyerCartLineProjection, BuyerCartProjection, BuyerCartReplaceConfirmationProjection, + BuyerCheckoutDraft, BuyerCheckoutProjection, BuyerCheckoutSummaryProjection, BuyerContext, + BuyerListingRow, BuyerListingsProjection, BuyerOrderDetailProjection, BuyerOrderStatus, + BuyerOrdersListRow, BuyerOrdersProjection, BuyerProductDetailProjection, FarmId, + FarmOrderMethod, FulfillmentWindowId, OrderDetailItemRow, OrderId, OrderStatus, ProductId, + ProductAvailabilityState, ProductAvailabilitySummary, ProductPricePresentation, ProductStatus, + ProductStockState, ProductStockSummary, RepeatDemandEligibility, + RepeatDemandHandoffProjection, }; use rusqlite::{Connection, OptionalExtension, params}; @@ -14,6 +16,13 @@ use crate::AppSqliteError; const BUYER_LOW_STOCK_THRESHOLD: u32 = 3; +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BuyerRepeatDemandApplyOutcome { + Applied, + ConfirmationRequired(BuyerCartReplaceConfirmationProjection), + Unavailable, +} + pub struct AppBuyerRepository<'a> { connection: &'a Connection, } @@ -397,6 +406,8 @@ impl<'a> AppBuyerRepository<'a> { &self, context: &BuyerContext, ) -> Result<BuyerOrdersProjection, AppSqliteError> { + let now_utc = self.current_utc_timestamp()?; + let visible_listings = self.visible_listing_records_by_id(&now_utc)?; let context_key = context.storage_key(); let mut statement = self .connection @@ -455,9 +466,15 @@ impl<'a> AppBuyerRepository<'a> { })?; orders.push(BuyerOrdersListRow { - order_id: parse_typed_id("orders.id", order_id)?, - farm_id: parse_typed_id("orders.farm_id", farm_id)?, + order_id: parse_typed_id("orders.id", order_id.clone())?, + farm_id: parse_typed_id("orders.farm_id", farm_id.clone())?, order_number, + repeat_demand: self.build_repeat_demand_handoff( + parse_typed_id("orders.id", order_id)?, + parse_typed_id("orders.farm_id", farm_id)?, + farm_display_name.as_str(), + &visible_listings, + )?, farm_display_name, fulfillment_summary: format_fulfillment_summary( fulfillment_label, @@ -465,7 +482,6 @@ impl<'a> AppBuyerRepository<'a> { fulfillment_ends_at, ), status: BuyerOrderStatus::from(parse_order_status("orders.status", status)?), - repeat_demand: None, }); } @@ -477,6 +493,8 @@ impl<'a> AppBuyerRepository<'a> { context: &BuyerContext, order_id: OrderId, ) -> Result<Option<BuyerOrderDetailProjection>, AppSqliteError> { + let now_utc = self.current_utc_timestamp()?; + let visible_listings = self.visible_listing_records_by_id(&now_utc)?; let context_key = context.storage_key(); let record = self .connection @@ -530,11 +548,13 @@ impl<'a> AppBuyerRepository<'a> { fulfillment_starts_at, fulfillment_ends_at, )| { + let order_id = parse_typed_id("orders.id", order_id)?; + let farm_id = parse_typed_id("orders.farm_id", farm_id)?; Ok(BuyerOrderDetailProjection { - order_id: parse_typed_id("orders.id", order_id.clone())?, - farm_id: parse_typed_id("orders.farm_id", farm_id)?, + order_id, + farm_id, order_number, - farm_display_name, + farm_display_name: farm_display_name.clone(), fulfillment_summary: format_fulfillment_summary( fulfillment_label, fulfillment_starts_at, @@ -544,15 +564,86 @@ impl<'a> AppBuyerRepository<'a> { "orders.status", status, )?), - items: self.load_order_detail_items(order_id)?, + items: self.load_order_detail_items(order_id.to_string())?, order_note: empty_string_to_none(order_note), - repeat_demand: None, + repeat_demand: self.build_repeat_demand_handoff( + order_id, + farm_id, + farm_display_name.as_str(), + &visible_listings, + )?, }) }, ) .transpose() } + pub fn apply_buyer_repeat_demand_to_cart( + &self, + context: &BuyerContext, + order_id: OrderId, + replace_existing: bool, + ) -> Result<BuyerRepeatDemandApplyOutcome, AppSqliteError> { + let Some((farm_id, farm_display_name)) = self.load_buyer_order_repeat_demand_header( + context, + order_id, + )? + else { + return Ok(BuyerRepeatDemandApplyOutcome::Unavailable); + }; + let now_utc = self.current_utc_timestamp()?; + let visible_listings = self.visible_listing_records_by_id(&now_utc)?; + let Some(candidate) = self.build_repeat_demand_candidate( + order_id, + farm_id, + farm_display_name.as_str(), + &visible_listings, + )? + else { + return Ok(BuyerRepeatDemandApplyOutcome::Unavailable); + }; + if candidate.available_lines.is_empty() { + return Ok(BuyerRepeatDemandApplyOutcome::Unavailable); + } + + let current_cart = self.load_buyer_cart(context)?; + if !replace_existing + && !current_cart.is_empty() + && current_cart.farm_id != Some(candidate.farm_id) + { + let current_farm_display_name = current_cart + .farm_display_name + .clone() + .or_else(|| { + current_cart + .lines + .first() + .map(|line| line.farm_display_name.clone()) + }) + .ok_or(AppSqliteError::InvalidProjection { + reason: "buyer cart farm display name is missing", + })?; + + return Ok(BuyerRepeatDemandApplyOutcome::ConfirmationRequired( + BuyerCartReplaceConfirmationProjection { + current_farm_display_name, + incoming_farm_display_name: candidate.farm_display_name, + }, + )); + } + + let next_cart = next_buyer_cart_for_repeat_demand( + current_cart, + candidate.farm_id, + candidate.farm_display_name.as_str(), + &candidate.available_lines, + replace_existing, + )?; + self.replace_buyer_cart(context, &next_cart)?; + + Ok(BuyerRepeatDemandApplyOutcome::Applied) + } + fn build_cart_projection( &self, header: Option<BuyerCartHeader>, @@ -991,6 +1082,168 @@ impl<'a> AppBuyerRepository<'a> { }) } + fn load_repeat_demand_order_lines( + &self, + order_id: OrderId, + ) -> Result<Vec<RepeatDemandOrderLine>, AppSqliteError> { + let mut statement = self + .connection + .prepare( + "select id, quantity_value + from order_lines + where order_id = ?1 + order by sort_index asc, id asc", + ) + .map_err(|source| AppSqliteError::Query { + operation: "prepare repeat demand order lines", + source, + })?; + let rows = statement + .query_map(params![order_id.to_string()], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + }) + .map_err(|source| AppSqliteError::Query { + operation: "query repeat demand order lines", + source, + })?; + let mut order_lines = Vec::new(); + + for row in rows { + let (line_id, quantity_value) = row.map_err(|source| AppSqliteError::Query { + operation: "read repeat demand order lines", + source, + })?; + let product_id = parse_repeat_demand_product_id(line_id.as_str())?; + let quantity = u32::try_from(quantity_value).map_err(|_| { + AppSqliteError::InvalidProjection { + reason: "repeat demand quantity must be non-negative", + } + })?; + if quantity == 0 { + return Err(AppSqliteError::InvalidProjection { + reason: "repeat demand quantity must be positive", + }); + } + + order_lines.push(RepeatDemandOrderLine { + product_id, + quantity, + }); + } + + Ok(order_lines) + } + + fn load_buyer_order_repeat_demand_header( + &self, + context: &BuyerContext, + order_id: OrderId, + ) -> Result<Option<(FarmId, String)>, AppSqliteError> { + let context_key = context.storage_key(); + + self.connection + .query_row( + "select o.farm_id, f.display_name + from orders o + inner join farms f on f.id = o.farm_id + where o.buyer_context_key = ?1 and o.id = ?2 + limit 1", + params![context_key.as_str(), order_id.to_string()], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional() + .map_err(|source| AppSqliteError::Query { + operation: "load buyer repeat demand header", + source, + })? + .map(|(farm_id, farm_display_name)| { + Ok(( + parse_typed_id("orders.farm_id", farm_id)?, + farm_display_name, + )) + }) + .transpose() + } + + fn visible_listing_records_by_id( + &self, + now_utc: &str, + ) -> Result<BTreeMap<ProductId, BuyerListingRecord>, AppSqliteError> { + Ok(self + .load_listing_records()? + .into_iter() + .filter(|record| record.is_buyer_visible(now_utc)) + .map(|record| (record.product_id, record)) + .collect()) + } + + fn build_repeat_demand_handoff( + &self, + order_id: OrderId, + farm_id: FarmId, + farm_display_name: &str, + visible_listings: &BTreeMap<ProductId, BuyerListingRecord>, + ) -> Result<Option<RepeatDemandHandoffProjection>, AppSqliteError> { + Ok(self + .build_repeat_demand_candidate(order_id, farm_id, farm_display_name, visible_listings)? + .map(|candidate| candidate.handoff)) + } + + fn build_repeat_demand_candidate( + &self, + order_id: OrderId, + farm_id: FarmId, + farm_display_name: &str, + visible_listings: &BTreeMap<ProductId, BuyerListingRecord>, + ) -> Result<Option<RepeatDemandCandidate>, AppSqliteError> { + let order_lines = self.load_repeat_demand_order_lines(order_id)?; + if order_lines.is_empty() { + return Ok(None); + } + + let mut available_lines = Vec::new(); + let mut unavailable_item_count = 0u32; + + for order_line in &order_lines { + if let Some(listing) = visible_listings.get(&order_line.product_id).cloned() { + available_lines.push(BuyerCartLineRecord { + listing, + quantity: order_line.quantity, + }); + } else { + unavailable_item_count = unavailable_item_count.checked_add(1).ok_or( + AppSqliteError::InvalidProjection { + reason: "repeat demand unavailable count overflowed", + }, + )?; + } + } + + let available_item_count = available_lines.len() as u32; + let eligibility = if available_item_count == 0 { + RepeatDemandEligibility::Unavailable + } else if unavailable_item_count == 0 { + RepeatDemandEligibility::Eligible + } else { + RepeatDemandEligibility::Partial + }; + + Ok(Some(RepeatDemandCandidate { + farm_id, + farm_display_name: farm_display_name.to_owned(), + available_lines, + handoff: RepeatDemandHandoffProjection { + order_id, + farm_id, + eligibility, + available_item_count, + unavailable_item_count, + action_label: repeat_demand_action_label(eligibility), + note: repeat_demand_note(available_item_count, unavailable_item_count), + }, + })) + } + fn load_farm_display_name(&self, farm_id: FarmId) -> Result<Option<String>, AppSqliteError> { self.connection .query_row( @@ -1257,6 +1510,20 @@ impl BuyerCartLineRecord { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct RepeatDemandOrderLine { + product_id: ProductId, + quantity: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct RepeatDemandCandidate { + farm_id: FarmId, + farm_display_name: String, + available_lines: Vec<BuyerCartLineRecord>, + handoff: RepeatDemandHandoffProjection, +} + fn validate_cart_projection(cart: &BuyerCartProjection) -> Result<(), AppSqliteError> { if cart.lines.is_empty() { return Ok(()); @@ -1282,6 +1549,116 @@ fn validate_cart_projection(cart: &BuyerCartProjection) -> Result<(), AppSqliteE Ok(()) } +fn next_buyer_cart_for_repeat_demand( + mut current_cart: BuyerCartProjection, + farm_id: FarmId, + farm_display_name: &str, + lines: &[BuyerCartLineRecord], + replace_existing: bool, +) -> Result<BuyerCartProjection, AppSqliteError> { + if replace_existing || current_cart.is_empty() || current_cart.farm_id != Some(farm_id) { + current_cart.lines.clear(); + } + + current_cart.farm_id = Some(farm_id); + current_cart.farm_display_name = Some(farm_display_name.to_owned()); + current_cart.replace_confirmation = None; + + for line in lines { + let incoming_line = line.clone().into_projection()?; + + if let Some(existing_line) = current_cart + .lines + .iter_mut() + .find(|existing| existing.product_id == incoming_line.product_id) + { + existing_line.quantity = existing_line + .quantity + .checked_add(incoming_line.quantity) + .ok_or(AppSqliteError::InvalidProjection { + reason: "buyer cart quantity overflow", + })?; + existing_line.line_total_minor_units = existing_line + .unit_price + .amount_minor_units + .checked_mul(existing_line.quantity) + .ok_or(AppSqliteError::InvalidProjection { + reason: "buyer cart line total overflow", + })?; + existing_line.fulfillment_summary = incoming_line.fulfillment_summary.clone(); + continue; + } + + current_cart.lines.push(incoming_line); + } + + refresh_buyer_cart_summary(&mut current_cart)?; + + Ok(current_cart) +} + +fn parse_repeat_demand_product_id(line_id: &str) -> Result<ProductId, AppSqliteError> { + let Some((_, product_id)) = line_id.rsplit_once(':') else { + return Err(AppSqliteError::InvalidProjection { + reason: "repeat demand order line is missing a product id", + }); + }; + + parse_typed_id("order_lines.id", product_id.to_owned()) +} + +fn repeat_demand_action_label(eligibility: RepeatDemandEligibility) -> String { + match eligibility { + RepeatDemandEligibility::Eligible => "Reorder".to_owned(), + RepeatDemandEligibility::Partial => "Reorder available items".to_owned(), + RepeatDemandEligibility::Unavailable => "Unavailable".to_owned(), + } +} + +fn repeat_demand_note( + available_item_count: u32, + unavailable_item_count: u32, +) -> Option<String> { + match (available_item_count, unavailable_item_count) { + (0, 0) => None, + (_, 0) => Some("All items from this order are currently available.".to_owned()), + (0, _) => Some("The items from this order are no longer available.".to_owned()), + (_, unavailable) if unavailable == 1 => { + Some("One item from this order is no longer available.".to_owned()) + } + (_, unavailable) => Some(format!( + "{unavailable} items from this order are no longer available." + )), + } +} + +fn refresh_buyer_cart_summary(cart: &mut BuyerCartProjection) -> Result<(), AppSqliteError> { + if cart.lines.is_empty() { + cart.subtotal_minor_units = None; + cart.currency_code = None; + cart.replace_confirmation = None; + return Ok(()); + } + + let mut subtotal_minor_units = 0_u32; + let mut currency_code = None; + + for line in &cart.lines { + subtotal_minor_units = subtotal_minor_units + .checked_add(line.line_total_minor_units) + .ok_or(AppSqliteError::InvalidProjection { + reason: "buyer cart subtotal overflowed", + })?; + currency_code + .get_or_insert_with(|| line.unit_price.currency_code.clone()); + } + + cart.subtotal_minor_units = Some(subtotal_minor_units); + cart.currency_code = Some(currency_code.unwrap_or_default()); + + Ok(()) +} + fn shared_fulfillment_summary(lines: &[BuyerCartLineProjection]) -> Option<String> { let first = lines.first()?.fulfillment_summary.clone(); @@ -1690,6 +2067,150 @@ mod tests { } #[test] + fn buyer_order_history_derives_repeat_demand_from_current_listing_truth() { + let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); + let connection = store.connection(); + let repository = AppBuyerRepository::new(connection); + let context = BuyerContext::Guest; + let farm_id = insert_farm(connection, "Willow Farm", "ready"); + let pickup_location_id = insert_pickup_location(connection, farm_id, "Barn pickup"); + let future_window_id = insert_window( + connection, + farm_id, + Some(pickup_location_id), + "Friday pickup", + "2099-04-18T16:00:00Z", + "2099-04-18T18:00:00Z", + ); + + insert_farm_setup_binding(connection, "acct_farmer", farm_id, true, false, false); + let available_product_id = insert_product( + connection, + farm_id, + SeedProduct { + title: "Salad mix", + subtitle: "Spring blend", + status: "published", + unit_label: "bag", + price_minor_units: Some(650), + price_currency: "USD", + stock_count: Some(8), + availability_window_id: Some(future_window_id), + }, + ); + let unavailable_product_id = insert_product( + connection, + farm_id, + SeedProduct { + title: "Pea shoots", + subtitle: "Tray-grown", + status: "published", + unit_label: "bag", + price_minor_units: Some(450), + price_currency: "USD", + stock_count: Some(4), + availability_window_id: Some(future_window_id), + }, + ); + let available_listing = repository + .load_buyer_product_detail(available_product_id) + .expect("available buyer detail should load") + .expect("available listing should exist") + .listing; + let unavailable_listing = repository + .load_buyer_product_detail(unavailable_product_id) + .expect("unavailable buyer detail should load") + .expect("unavailable listing should exist") + .listing; + + repository + .replace_buyer_cart( + &context, + &radroots_app_models::BuyerCartProjection { + farm_id: Some(farm_id), + farm_display_name: Some("Willow Farm".to_owned()), + lines: vec![ + radroots_app_models::BuyerCartLineProjection { + product_id: available_listing.product_id, + farm_id: available_listing.farm_id, + farm_display_name: available_listing.farm_display_name.clone(), + title: available_listing.title.clone(), + quantity: 2, + unit_price: available_listing.price.clone(), + line_total_minor_units: 1300, + fulfillment_summary: "Friday pickup".to_owned(), + }, + radroots_app_models::BuyerCartLineProjection { + product_id: unavailable_listing.product_id, + farm_id: unavailable_listing.farm_id, + farm_display_name: unavailable_listing.farm_display_name.clone(), + title: unavailable_listing.title.clone(), + quantity: 1, + unit_price: unavailable_listing.price.clone(), + line_total_minor_units: 450, + fulfillment_summary: "Friday pickup".to_owned(), + }, + ], + subtotal_minor_units: Some(1750), + currency_code: Some("USD".to_owned()), + replace_confirmation: None, + }, + ) + .expect("buyer cart should save"); + repository + .save_buyer_checkout_draft( + &context, + &radroots_app_models::BuyerCheckoutDraft { + name: "Casey Buyer".to_owned(), + email: "casey@example.com".to_owned(), + phone: String::new(), + order_note: String::new(), + }, + ) + .expect("buyer checkout draft should save"); + let order_id = repository + .place_buyer_order(&context) + .expect("buyer checkout should place order"); + + connection + .execute( + "update products set status = 'archived' where id = ?1", + params![unavailable_product_id.to_string()], + ) + .expect("product should archive"); + + let buyer_orders = repository + .load_buyer_orders(&context) + .expect("buyer orders should load"); + let buyer_order_detail = repository + .load_buyer_order_detail(&context, order_id) + .expect("buyer order detail should load") + .expect("buyer order detail should exist"); + let row_repeat_demand = buyer_orders.rows[0] + .repeat_demand + .as_ref() + .expect("repeat demand should derive for buyer order row"); + let detail_repeat_demand = buyer_order_detail + .repeat_demand + .as_ref() + .expect("repeat demand should derive for buyer order detail"); + + assert_eq!(buyer_orders.rows.len(), 1); + assert_eq!( + row_repeat_demand.eligibility, + radroots_app_models::RepeatDemandEligibility::Partial + ); + assert_eq!(row_repeat_demand.available_item_count, 1); + assert_eq!(row_repeat_demand.unavailable_item_count, 1); + assert_eq!(row_repeat_demand.action_label, "Reorder available items"); + assert_eq!( + row_repeat_demand.note.as_deref(), + Some("One item from this order is no longer available.") + ); + assert_eq!(detail_repeat_demand, row_repeat_demand); + } + + #[test] fn buyer_orders_filter_to_context_and_ignore_seller_orders() { let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); let connection = store.connection(); diff --git a/crates/shared/sqlite/src/lib.rs b/crates/shared/sqlite/src/lib.rs @@ -35,7 +35,7 @@ pub use activation::AppActivationRepository; pub use activity::{ APP_ACTIVITY_CONTEXT_LIMIT, APP_ACTIVITY_RETENTION_LIMIT, AppActivityRepository, }; -pub use buyer::AppBuyerRepository; +pub use buyer::{AppBuyerRepository, BuyerRepeatDemandApplyOutcome}; pub use error::AppSqliteError; pub use farm_rules::{AppFarmRulesRepository, derive_farm_rules_readiness}; pub use farm_setup::AppFarmSetupRepository; @@ -442,6 +442,16 @@ impl AppSqliteStore { .load_buyer_order_detail(context, order_id) } + pub fn apply_buyer_repeat_demand_to_cart( + &self, + context: &BuyerContext, + order_id: OrderId, + replace_existing: bool, + ) -> Result<BuyerRepeatDemandApplyOutcome, AppSqliteError> { + self.buyer_repository() + .apply_buyer_repeat_demand_to_cart(context, order_id, replace_existing) + } + pub fn enqueue_pending_sync_operation( &self, account_id: &str, diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -100,6 +100,7 @@ "personal.orders.detail.fulfillment.label": "Fulfillment", "personal.orders.detail.note.label": "Order note", "personal.orders.detail.items.title": "Items", + "personal.orders.repeat_demand.title": "Reorder", "personal.orders.status.placed": "Placed", "personal.orders.status.scheduled": "Scheduled", "personal.orders.status.ready": "Ready",