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:
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",