app

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

commit e0aac9e8afc0dfdfb95f765b86b38bbdacd3ca52
parent ee384d31ee7217eea73246fe046fbf29bb6dd8ac
Author: triesap <tyson@radroots.org>
Date:   Thu,  4 Jun 2026 17:13:34 -0700

app: align linked buyer lifecycle actions

- add typed selected-buyer order scope for account and linked Nostr reads
- route buyer detail, cancellation, receipt, revision, and repeat demand through the typed scope
- keep repeat-demand cart writes account-owned while reading linked source orders
- cover linked order open, cancellation, receipt, and repeat-demand cart scope

Diffstat:
Mcrates/desktop/src/lib.rs | 1+
Mcrates/desktop/src/runtime.rs | 508+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/store/src/lib.rs | 35+++++++++++++++++++++++++----------
Mcrates/store/src/repo/buyer.rs | 258++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mcrates/store/src/repo/mod.rs | 1+
5 files changed, 708 insertions(+), 95 deletions(-)

diff --git a/crates/desktop/src/lib.rs b/crates/desktop/src/lib.rs @@ -11,6 +11,7 @@ mod runtime; mod source_guards; mod window; +pub use accounts::DesktopLocalIdentityImportRequest; pub use app::AppLaunchError; pub use runtime::DesktopAppRuntime; diff --git a/crates/desktop/src/runtime.rs b/crates/desktop/src/runtime.rs @@ -18,8 +18,9 @@ use radroots_app_remote_signer::{ use radroots_app_sqlite::{ APP_ACTIVITY_CONTEXT_LIMIT, AppLocalInteropImportReport, AppSqliteError, AppSqliteStore, BuyerOrderLocalEventExport, BuyerOrderLocalEventLine, BuyerRepeatDemandApplyOutcome, - DatabaseTarget, SellerOrderDecisionExport, StoredPendingSyncOperation, StoredRelayIngestCursor, - StoredSyncConflict, derive_farm_rules_readiness, projected_order_id_from_trade_request, + DatabaseTarget, SelectedBuyerOrderScope, SellerOrderDecisionExport, StoredPendingSyncOperation, + StoredRelayIngestCursor, StoredSyncConflict, derive_farm_rules_readiness, + projected_order_id_from_trade_request, }; use radroots_app_state::{ APP_STATE_FILE_NAME, AppShellProjection, AppStateCommand, AppStatePersistenceRepository, @@ -2021,8 +2022,7 @@ impl DesktopAppRuntimeState { .detail .as_ref() .map(|detail| detail.order_id); - let buyer_order_context_keys = - buyer_order_context_keys(self.state_store.identity_projection()); + let buyer_order_scope = selected_buyer_order_scope(self.state_store.identity_projection()); let ( refreshed_cart, refreshed_order_review, @@ -2035,8 +2035,7 @@ impl DesktopAppRuntimeState { }; let refreshed_cart = sqlite_store.load_buyer_cart(buyer_context)?; let refreshed_order_review = sqlite_store.load_buyer_order_review(buyer_context)?; - let refreshed_orders = - sqlite_store.load_buyer_orders_for_context_keys(&buyer_order_context_keys)?; + let refreshed_orders = sqlite_store.load_buyer_orders_for_scope(&buyer_order_scope)?; let has_recoverable_coordination = !sqlite_store .load_recoverable_buyer_order_coordination_records(buyer_context)? .is_empty(); @@ -2056,10 +2055,9 @@ impl DesktopAppRuntimeState { }) }); let refreshed_order_detail = match detail_order_id { - Some(order_id) => sqlite_store.load_buyer_order_detail_for_context_keys( - &buyer_order_context_keys, - order_id, - )?, + Some(order_id) => { + sqlite_store.load_buyer_order_detail_for_scope(&buyer_order_scope, order_id)? + } None => None, }; ( @@ -2102,10 +2100,9 @@ impl DesktopAppRuntimeState { let Some(sqlite_store) = self.sqlite_store.as_ref() else { return Ok(false); }; - let buyer_order_context_keys = - buyer_order_context_keys(self.state_store.identity_projection()); - let Some(order_detail) = sqlite_store - .load_buyer_order_detail_for_context_keys(&buyer_order_context_keys, order_id)? + let buyer_order_scope = selected_buyer_order_scope(self.state_store.identity_projection()); + let Some(order_detail) = + sqlite_store.load_buyer_order_detail_for_scope(&buyer_order_scope, order_id)? else { return Ok(false); }; @@ -2125,8 +2122,10 @@ impl DesktopAppRuntimeState { return Ok(false); }; let buyer_context = self.state_store.identity_projection().buyer_context(); + let buyer_order_scope = selected_buyer_order_scope(self.state_store.identity_projection()); - match sqlite_store.apply_buyer_repeat_demand_to_cart( + match sqlite_store.apply_buyer_repeat_demand_from_scope_to_cart( + &buyer_order_scope, &buyer_context, order_id, replace_existing, @@ -2135,9 +2134,10 @@ impl DesktopAppRuntimeState { let refreshed_cart = sqlite_store.load_buyer_cart(&buyer_context)?; let refreshed_order_review = sqlite_store.load_buyer_order_review(&buyer_context)?; - let refreshed_orders = sqlite_store.load_buyer_orders(&buyer_context)?; + let refreshed_orders = + sqlite_store.load_buyer_orders_for_scope(&buyer_order_scope)?; let refreshed_detail = - sqlite_store.load_buyer_order_detail(&buyer_context, order_id)?; + sqlite_store.load_buyer_order_detail_for_scope(&buyer_order_scope, order_id)?; let personal_changed = self.mutate_personal_projection(|projection| { let mut changed = false; if projection.cart.cart != refreshed_cart { @@ -2893,7 +2893,10 @@ impl DesktopAppRuntimeState { reason: "buyer order revision requires local state", }); }; - let Some(detail) = sqlite_store.load_buyer_order_detail(&buyer_context, order_id)? else { + let buyer_order_scope = selected_buyer_order_scope(self.state_store.identity_projection()); + let Some(detail) = + sqlite_store.load_buyer_order_detail_for_scope(&buyer_order_scope, order_id)? + else { return Err(AppSqliteError::InvalidProjection { reason: "buyer order revision requires a visible buyer order", }); @@ -3033,7 +3036,10 @@ impl DesktopAppRuntimeState { reason: "buyer order cancellation requires local state", }); }; - let Some(detail) = sqlite_store.load_buyer_order_detail(&buyer_context, order_id)? else { + let buyer_order_scope = selected_buyer_order_scope(self.state_store.identity_projection()); + let Some(detail) = + sqlite_store.load_buyer_order_detail_for_scope(&buyer_order_scope, order_id)? + else { return Err(AppSqliteError::InvalidProjection { reason: "buyer order cancellation requires a visible buyer order", }); @@ -3152,7 +3158,10 @@ impl DesktopAppRuntimeState { reason: "buyer order receipt requires local state", }); }; - let Some(detail) = sqlite_store.load_buyer_order_detail(&buyer_context, order_id)? else { + let buyer_order_scope = selected_buyer_order_scope(self.state_store.identity_projection()); + let Some(detail) = + sqlite_store.load_buyer_order_detail_for_scope(&buyer_order_scope, order_id)? + else { return Err(AppSqliteError::InvalidProjection { reason: "buyer order receipt requires a visible buyer order", }); @@ -7972,17 +7981,21 @@ fn load_selected_account_context( ) } -fn buyer_order_context_keys(identity_projection: &AppIdentityProjection) -> Vec<String> { - let mut context_keys = vec![identity_projection.buyer_context().storage_key()]; - if let Some(selected_account) = identity_projection.selected_account.as_ref() - && let Some(public_key_hex) = selected_account_public_key_hex(selected_account) - { - let nostr_context_key = format!("nostr:{public_key_hex}"); - if !context_keys.contains(&nostr_context_key) { - context_keys.push(nostr_context_key); - } +fn selected_buyer_order_scope( + identity_projection: &AppIdentityProjection, +) -> SelectedBuyerOrderScope { + let buyer_context = identity_projection.buyer_context(); + match &buyer_context { + BuyerContext::Account(account_id) => SelectedBuyerOrderScope::for_selected_account( + account_id, + identity_projection + .selected_account + .as_ref() + .and_then(selected_account_public_key_hex) + .as_deref(), + ), + BuyerContext::Guest => SelectedBuyerOrderScope::from_buyer_context(&buyer_context), } - context_keys } fn selected_account_public_key_hex( @@ -8006,7 +8019,7 @@ fn load_selected_account_context_with_options( allow_auto_present: bool, ) -> Result<DesktopSelectedAccountContext, AppSqliteError> { let buyer_context = identity_projection.buyer_context(); - let buyer_order_context_keys = buyer_order_context_keys(identity_projection); + let buyer_order_scope = selected_buyer_order_scope(identity_projection); let browse_fulfillment_methods = BTreeSet::new(); let browse_listings = sqlite_store.load_buyer_listings("", &browse_fulfillment_methods)?; let search_query = continuity_state.buyer.search_query.clone(); @@ -8024,14 +8037,14 @@ fn load_selected_account_context_with_options( }; let buyer_cart = sqlite_store.load_buyer_cart(&buyer_context)?; let buyer_order_review = sqlite_store.load_buyer_order_review(&buyer_context)?; - let buyer_orders = - sqlite_store.load_buyer_orders_for_context_keys(&buyer_order_context_keys)?; + let buyer_orders = sqlite_store.load_buyer_orders_for_scope(&buyer_order_scope)?; let has_recoverable_coordination = !sqlite_store .load_recoverable_buyer_order_coordination_records(&buyer_context)? .is_empty(); let buyer_order_detail = match continuity_state.buyer.orders_detail_order_id { - Some(order_id) => sqlite_store - .load_buyer_order_detail_for_context_keys(&buyer_order_context_keys, order_id)?, + Some(order_id) => { + sqlite_store.load_buyer_order_detail_for_scope(&buyer_order_scope, order_id)? + } None => None, }; let personal_projection = PersonalWorkspaceProjection { @@ -9688,9 +9701,11 @@ mod tests { }; use radroots_sdk::RadrootsNostrEventPtr; use radroots_sdk::trade::{ - RadrootsActiveTradeFulfillmentState, RadrootsTradeOrderDecision, - RadrootsTradeOrderEconomicItem, RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, - RadrootsTradeOrderRequested, RadrootsTradeOrderRevisionDecision, RadrootsTradePricingBasis, + RadrootsActiveTradeFulfillmentState, RadrootsTradeFulfillmentUpdated, + RadrootsTradeInventoryCommitment, RadrootsTradeOrderDecision, + RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderEconomicItem, + RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, RadrootsTradeOrderRequested, + RadrootsTradeOrderRevisionDecision, RadrootsTradePricingBasis, }; use radroots_sql_core::{SqlExecutor, SqliteExecutor}; use serde_json::json; @@ -15536,6 +15551,170 @@ mod tests { } #[test] + fn runtime_opens_linked_buyer_order_detail_from_selected_account_nostr_scope() { + let fixture = linked_buyer_lifecycle_runtime("linked_buyer_order_open", false); + let report = fixture + .runtime + .refresh_shared_local_events() + .expect("linked buyer local events should import"); + assert!(report.imported_records > 0); + + assert!( + fixture + .runtime + .open_personal_order_detail(fixture.order_id) + .expect("linked buyer order detail should open") + ); + + let summary = fixture.runtime.summary(); + let row = summary + .personal_projection + .orders + .list + .rows + .iter() + .find(|row| row.order_id == fixture.order_id) + .expect("linked buyer order row should exist"); + let detail = summary + .personal_projection + .orders + .detail + .as_ref() + .expect("linked buyer order detail should exist"); + assert_eq!(row.status, BuyerOrderStatus::Scheduled); + assert_eq!(detail.order_id, fixture.order_id); + assert_eq!(detail.status, BuyerOrderStatus::Scheduled); + assert_eq!( + detail.workflow.provenance.last_event_id.as_deref(), + Some(fixture.decision_event_id.as_str()) + ); + + cleanup_bootstrapped_runtime_paths(&fixture.paths); + } + + #[test] + fn runtime_publishes_linked_buyer_cancellation_from_selected_account_nostr_scope() { + let relay = ThreadedAckRelay::spawn(); + let fixture = linked_buyer_lifecycle_runtime("linked_buyer_order_cancel", false); + install_direct_relay_sync_transport(&fixture.runtime, &relay); + fixture + .runtime + .refresh_shared_local_events() + .expect("linked buyer local events should import"); + assert!( + fixture + .runtime + .open_personal_order_detail(fixture.order_id) + .expect("linked buyer order detail should open") + ); + + assert!( + fixture + .runtime + .publish_buyer_order_cancel(fixture.order_id) + .expect("linked buyer cancellation should publish") + ); + + assert_eq!( + persisted_order_status(&fixture.runtime, fixture.order_id), + "declined" + ); + assert_eq!(relay.event_count(), 1); + let cancellation_events = + shared_order_events_by_kind(&fixture.paths, 3432, fixture.buyer_pubkey.as_str()); + assert_eq!(cancellation_events.len(), 1); + let cancellation_event = cancellation_events + .first() + .expect("linked buyer cancellation event"); + let cancellation = radroots_sdk::trade::parse_order_cancellation(cancellation_event) + .expect("linked buyer cancellation should parse"); + assert_eq!(cancellation.payload.order_id, fixture.trade_order_id); + assert_eq!(cancellation.payload.buyer_pubkey, fixture.buyer_pubkey); + assert_eq!(cancellation.payload.seller_pubkey, fixture.seller_pubkey); + assert!(event_has_tag( + cancellation_event, + "e_root", + fixture.request_event_id.as_str() + )); + assert!(event_has_tag( + cancellation_event, + "e_prev", + fixture.decision_event_id.as_str() + )); + + cleanup_bootstrapped_runtime_paths(&fixture.paths); + } + + #[test] + fn runtime_publishes_linked_buyer_receipt_from_selected_account_nostr_scope() { + let relay = ThreadedAckRelay::spawn(); + let fixture = linked_buyer_lifecycle_runtime("linked_buyer_order_receipt", true); + let fulfillment_event_id = fixture + .fulfillment_event_id + .as_deref() + .expect("ready fixture should include fulfillment event") + .to_owned(); + install_direct_relay_sync_transport(&fixture.runtime, &relay); + fixture + .runtime + .refresh_shared_local_events() + .expect("linked buyer local events should import"); + assert!( + fixture + .runtime + .open_personal_order_detail(fixture.order_id) + .expect("linked ready buyer order detail should open") + ); + assert_eq!( + fixture + .runtime + .summary() + .personal_projection + .orders + .detail + .as_ref() + .expect("linked ready buyer detail") + .status, + BuyerOrderStatus::Ready + ); + + assert!( + fixture + .runtime + .publish_buyer_order_receipt(fixture.order_id) + .expect("linked buyer receipt should publish") + ); + + assert_eq!( + persisted_order_status(&fixture.runtime, fixture.order_id), + "completed" + ); + assert_eq!(relay.event_count(), 1); + let receipt_events = + shared_order_events_by_kind(&fixture.paths, 3434, fixture.buyer_pubkey.as_str()); + assert_eq!(receipt_events.len(), 1); + let receipt_event = receipt_events.first().expect("linked buyer receipt event"); + let receipt = radroots_sdk::trade::parse_buyer_receipt(receipt_event) + .expect("linked buyer receipt should parse"); + assert_eq!(receipt.payload.order_id, fixture.trade_order_id); + assert_eq!(receipt.payload.buyer_pubkey, fixture.buyer_pubkey); + assert_eq!(receipt.payload.seller_pubkey, fixture.seller_pubkey); + assert!(receipt.payload.received); + assert!(event_has_tag( + receipt_event, + "e_root", + fixture.request_event_id.as_str() + )); + assert!(event_has_tag( + receipt_event, + "e_prev", + fulfillment_event_id.as_str() + )); + + cleanup_bootstrapped_runtime_paths(&fixture.paths); + } + + #[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); @@ -18808,6 +18987,115 @@ mod tests { .expect("append signed buyer listing"); } + struct LinkedBuyerLifecycleFixture { + runtime: DesktopAppRuntime, + paths: AppDesktopRuntimePaths, + order_id: OrderId, + trade_order_id: String, + request_event_id: String, + decision_event_id: String, + fulfillment_event_id: Option<String>, + buyer_pubkey: String, + seller_pubkey: String, + } + + fn linked_buyer_lifecycle_runtime( + label: &str, + include_ready_fulfillment: bool, + ) -> LinkedBuyerLifecycleFixture { + let (runtime, paths) = bootstrapped_runtime(label); + assert!( + runtime + .generate_local_account(Some("Buyer".to_owned())) + .expect("buyer account should generate") + ); + assert!( + runtime + .select_active_surface(ActiveSurface::Personal) + .expect("buyer surface should select") + ); + let buyer_account_id = runtime + .summary() + .settings_account_projection + .selected_account + .as_ref() + .expect("selected buyer account") + .account + .account_id + .clone(); + let buyer_pubkey = runtime + .lock_state() + .accounts_manager + .as_ref() + .expect("accounts manager") + .resolve_account_selector(buyer_account_id.as_str()) + .expect("selected buyer account should resolve") + .public_identity + .public_key_hex; + let seller_pubkey = + "2222222222222222222222222222222222222222222222222222222222222222".to_owned(); + let farm_key = super::d_tag_from_uuid(FarmId::new().as_uuid()); + let listing_key = super::d_tag_from_uuid(ProductId::new().as_uuid()); + let listing_addr = format!("30402:{seller_pubkey}:{listing_key}"); + let listing_event_id = format!("event-app:signed_event:listing:{label}"); + let trade_order_id = format!("{label}-trade-order"); + let order_id = + projected_order_id_from_trade_request(trade_order_id.as_str(), buyer_pubkey.as_str()); + append_app_signed_listing_record( + &paths, + "linked-seller-account", + seller_pubkey.as_str(), + farm_key.as_str(), + listing_key.as_str(), + listing_event_id.as_str(), + 6, + ); + append_signed_order_request_record( + &paths, + trade_order_id.as_str(), + listing_addr.as_str(), + listing_event_id.as_str(), + buyer_pubkey.as_str(), + seller_pubkey.as_str(), + 2, + ); + let request_event_id = format!("event-app:signed_event:order-request:{trade_order_id}"); + let decision_event_id = append_signed_order_decision_record( + &paths, + trade_order_id.as_str(), + request_event_id.as_str(), + listing_addr.as_str(), + buyer_pubkey.as_str(), + seller_pubkey.as_str(), + 2, + ); + let fulfillment_event_id = if include_ready_fulfillment { + Some(append_signed_order_fulfillment_record( + &paths, + trade_order_id.as_str(), + request_event_id.as_str(), + decision_event_id.as_str(), + listing_addr.as_str(), + buyer_pubkey.as_str(), + seller_pubkey.as_str(), + )) + } else { + None + }; + + LinkedBuyerLifecycleFixture { + runtime, + paths, + order_id, + trade_order_id, + request_event_id, + decision_event_id, + fulfillment_event_id, + buyer_pubkey, + seller_pubkey, + } + } + fn seller_order_decision_runtime( label: &str, stock_count: u32, @@ -19152,6 +19440,150 @@ mod tests { .expect("append signed order request"); } + fn append_signed_order_decision_record( + paths: &AppDesktopRuntimePaths, + trade_order_id: &str, + request_event_id: &str, + listing_addr: &str, + buyer_pubkey: &str, + seller_pubkey: &str, + order_quantity: u32, + ) -> String { + let payload = RadrootsTradeOrderDecisionEvent { + order_id: trade_order_id.to_owned(), + listing_addr: listing_addr.to_owned(), + buyer_pubkey: buyer_pubkey.to_owned(), + seller_pubkey: seller_pubkey.to_owned(), + decision: RadrootsTradeOrderDecision::Accepted { + inventory_commitments: vec![RadrootsTradeInventoryCommitment { + bin_id: "seller-order-primary-bin".to_owned(), + bin_count: order_quantity, + }], + }, + }; + let parts = radroots_sdk::trade::build_order_decision_draft( + request_event_id, + request_event_id, + &payload, + ) + .expect("order decision draft should build") + .into_wire_parts(); + let record_id = format!("app:signed_event:order-decision:{trade_order_id}"); + let event_id = format!("event-{record_id}"); + append_trade_signed_event_record( + paths, + record_id.as_str(), + event_id.as_str(), + i64::from(parts.kind), + seller_pubkey, + listing_addr, + json!(parts.tags), + parts.content, + ); + event_id + } + + fn append_signed_order_fulfillment_record( + paths: &AppDesktopRuntimePaths, + trade_order_id: &str, + request_event_id: &str, + decision_event_id: &str, + listing_addr: &str, + buyer_pubkey: &str, + seller_pubkey: &str, + ) -> String { + let payload = RadrootsTradeFulfillmentUpdated { + order_id: trade_order_id.to_owned(), + listing_addr: listing_addr.to_owned(), + buyer_pubkey: buyer_pubkey.to_owned(), + seller_pubkey: seller_pubkey.to_owned(), + status: RadrootsActiveTradeFulfillmentState::ReadyForPickup, + }; + let parts = radroots_sdk::trade::build_fulfillment_update_draft( + request_event_id, + decision_event_id, + &payload, + ) + .expect("fulfillment update draft should build") + .into_wire_parts(); + let record_id = format!("app:signed_event:fulfillment:{trade_order_id}"); + let event_id = format!("event-{record_id}"); + append_trade_signed_event_record( + paths, + record_id.as_str(), + event_id.as_str(), + i64::from(parts.kind), + seller_pubkey, + listing_addr, + json!(parts.tags), + parts.content, + ); + event_id + } + + fn append_trade_signed_event_record( + paths: &AppDesktopRuntimePaths, + record_id: &str, + event_id: &str, + event_kind: i64, + event_pubkey: &str, + listing_addr: &str, + event_tags_json: serde_json::Value, + event_content: String, + ) { + let database_path = paths + .shared_local_events_database_path() + .expect("shared local events path"); + if let Some(parent) = database_path.parent() { + fs::create_dir_all(parent).expect("shared local events directory should create"); + } + let executor = + SqliteExecutor::open(database_path.as_path()).expect("open shared local events db"); + let store = LocalEventsStore::new(executor); + store.migrate_up().expect("migrate shared local events"); + let relay_delivery_json = RelayDeliveryEvidence::acknowledged( + ["wss://relay.example"], + ["wss://relay.example"], + ["wss://relay.example"], + Vec::new(), + ) + .expect("acknowledged relay delivery evidence") + .to_json_value() + .expect("acknowledged relay delivery json"); + store + .append_record(&LocalEventRecordInput { + record_id: record_id.to_owned(), + family: LocalRecordFamily::SignedEvent, + status: LocalRecordStatus::Published, + source_runtime: SourceRuntime::Test, + created_at_ms: 1_774_000_020_000, + inserted_at_ms: 1_774_000_020_001, + owner_account_id: None, + owner_pubkey: Some(event_pubkey.to_owned()), + farm_id: None, + listing_addr: Some(listing_addr.to_owned()), + local_work_json: None, + event_id: Some(event_id.to_owned()), + event_kind: Some(event_kind), + event_pubkey: Some(event_pubkey.to_owned()), + event_created_at: Some(1_774_000_020), + event_tags_json: Some(event_tags_json.clone()), + event_content: Some(event_content.clone()), + event_sig: Some("signature".to_owned()), + raw_event_json: Some(json!({ + "id": event_id, + "kind": event_kind, + "pubkey": event_pubkey, + "tags": event_tags_json, + "content": event_content + })), + outbox_status: PublishOutboxStatus::Acknowledged, + relay_set_fingerprint: Some("relay-set".to_owned()), + relay_delivery_json: Some(relay_delivery_json), + }) + .expect("append signed trade event"); + } + fn mark_shared_seller_order_request_evidence_pending(paths: &AppDesktopRuntimePaths) { let database_path = paths .shared_local_events_database_path() diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs @@ -37,9 +37,9 @@ pub use repo::{ AppActivityRepository, AppBuyerRepository, AppFarmRulesRepository, AppFarmSetupRepository, AppOrdersRepository, AppProductsRepository, AppRemindersRepository, AppTodayAgendaRepository, BuyerOrderCoordinationRecord, BuyerOrderCoordinationState, BuyerOrderLocalEventExport, - BuyerOrderLocalEventLine, BuyerRepeatDemandApplyOutcome, SellerOrderDecisionExport, - SellerOrderDecisionLineExport, TODAY_AGENDA_LIST_LIMIT, TODAY_AGENDA_LOW_STOCK_THRESHOLD, - derive_farm_rules_readiness, + BuyerOrderLocalEventLine, BuyerRepeatDemandApplyOutcome, SelectedBuyerOrderScope, + SellerOrderDecisionExport, SellerOrderDecisionLineExport, TODAY_AGENDA_LIST_LIMIT, + TODAY_AGENDA_LOW_STOCK_THRESHOLD, derive_farm_rules_readiness, }; pub use sync::{ AppSyncRepository, StoredPendingSyncOperation, StoredRelayIngestCursor, StoredSyncConflict, @@ -448,12 +448,11 @@ impl AppSqliteStore { self.buyer_repository().load_buyer_orders(context) } - pub fn load_buyer_orders_for_context_keys( + pub fn load_buyer_orders_for_scope( &self, - context_keys: &[String], + scope: &SelectedBuyerOrderScope, ) -> Result<BuyerOrdersProjection, AppSqliteError> { - self.buyer_repository() - .load_buyer_orders_for_context_keys(context_keys) + self.buyer_repository().load_buyer_orders_for_scope(scope) } pub fn load_buyer_order_detail( @@ -465,13 +464,13 @@ impl AppSqliteStore { .load_buyer_order_detail(context, order_id) } - pub fn load_buyer_order_detail_for_context_keys( + pub fn load_buyer_order_detail_for_scope( &self, - context_keys: &[String], + scope: &SelectedBuyerOrderScope, order_id: OrderId, ) -> Result<Option<BuyerOrderDetailProjection>, AppSqliteError> { self.buyer_repository() - .load_buyer_order_detail_for_context_keys(context_keys, order_id) + .load_buyer_order_detail_for_scope(scope, order_id) } pub fn load_buyer_order_local_event_export( @@ -552,6 +551,22 @@ impl AppSqliteStore { ) } + pub fn apply_buyer_repeat_demand_from_scope_to_cart( + &self, + source_scope: &SelectedBuyerOrderScope, + cart_context: &BuyerContext, + order_id: OrderId, + replace_existing: bool, + ) -> Result<BuyerRepeatDemandApplyOutcome, AppSqliteError> { + self.buyer_repository() + .apply_buyer_repeat_demand_from_scope_to_cart( + source_scope, + cart_context, + order_id, + replace_existing, + ) + } + pub fn enqueue_pending_sync_operation( &self, account_id: &str, diff --git a/crates/store/src/repo/buyer.rs b/crates/store/src/repo/buyer.rs @@ -29,6 +29,46 @@ pub enum BuyerRepeatDemandApplyOutcome { Unavailable, } +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct SelectedBuyerOrderScope { + context_keys: Vec<String>, +} + +impl SelectedBuyerOrderScope { + pub fn from_buyer_context(context: &BuyerContext) -> Self { + Self::from_context_keys([context.storage_key()]) + } + + pub fn for_selected_account( + account_id: impl AsRef<str>, + selected_account_pubkey: Option<&str>, + ) -> Self { + let mut context_keys = vec![format!("account:{}", account_id.as_ref().trim())]; + if let Some(pubkey) = selected_account_pubkey + .map(str::trim) + .filter(|pubkey| !pubkey.is_empty()) + { + context_keys.push(format!("nostr:{pubkey}")); + } + Self::from_context_keys(context_keys) + } + + fn from_context_keys(context_keys: impl IntoIterator<Item = String>) -> Self { + let mut unique = BTreeSet::new(); + let context_keys = context_keys + .into_iter() + .map(|key| key.trim().to_owned()) + .filter(|key| !key.is_empty()) + .filter(|key| unique.insert(key.clone())) + .collect(); + Self { context_keys } + } + + fn context_keys(&self) -> &[String] { + self.context_keys.as_slice() + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct BuyerOrderLocalEventExport { pub order_id: OrderId, @@ -736,17 +776,16 @@ impl<'a> AppBuyerRepository<'a> { &self, context: &BuyerContext, ) -> Result<BuyerOrdersProjection, AppSqliteError> { - let context_key = context.storage_key(); - self.load_buyer_orders_for_context_keys(std::slice::from_ref(&context_key)) + self.load_buyer_orders_for_scope(&SelectedBuyerOrderScope::from_buyer_context(context)) } - pub fn load_buyer_orders_for_context_keys( + pub fn load_buyer_orders_for_scope( &self, - context_keys: &[String], + scope: &SelectedBuyerOrderScope, ) -> Result<BuyerOrdersProjection, AppSqliteError> { let now_utc = self.current_utc_timestamp()?; let visible_listings = self.visible_listing_index(&now_utc)?; - let context_keys = normalized_buyer_context_keys(context_keys); + let context_keys = scope.context_keys(); if context_keys.is_empty() { return Ok(BuyerOrdersProjection::default()); } @@ -876,18 +915,20 @@ impl<'a> AppBuyerRepository<'a> { context: &BuyerContext, order_id: OrderId, ) -> Result<Option<BuyerOrderDetailProjection>, AppSqliteError> { - let context_key = context.storage_key(); - self.load_buyer_order_detail_for_context_keys(std::slice::from_ref(&context_key), order_id) + self.load_buyer_order_detail_for_scope( + &SelectedBuyerOrderScope::from_buyer_context(context), + order_id, + ) } - pub fn load_buyer_order_detail_for_context_keys( + pub fn load_buyer_order_detail_for_scope( &self, - context_keys: &[String], + scope: &SelectedBuyerOrderScope, order_id: OrderId, ) -> Result<Option<BuyerOrderDetailProjection>, AppSqliteError> { let now_utc = self.current_utc_timestamp()?; let visible_listings = self.visible_listing_index(&now_utc)?; - let context_keys = normalized_buyer_context_keys(context_keys); + let context_keys = scope.context_keys(); if context_keys.is_empty() { return Ok(None); } @@ -916,7 +957,7 @@ impl<'a> AppBuyerRepository<'a> { where o.buyer_context_key in ({placeholders}) and o.id = ? limit 1" ); - let mut params = context_keys.clone(); + let mut params = context_keys.to_vec(); params.push(order_id.to_string()); let record = self .connection @@ -1124,8 +1165,23 @@ impl<'a> AppBuyerRepository<'a> { order_id: OrderId, replace_existing: bool, ) -> Result<BuyerRepeatDemandApplyOutcome, AppSqliteError> { + self.apply_buyer_repeat_demand_from_scope_to_cart( + &SelectedBuyerOrderScope::from_buyer_context(context), + context, + order_id, + replace_existing, + ) + } + + pub fn apply_buyer_repeat_demand_from_scope_to_cart( + &self, + source_scope: &SelectedBuyerOrderScope, + cart_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)? + self.load_buyer_order_repeat_demand_header_for_scope(source_scope, order_id)? else { return Ok(BuyerRepeatDemandApplyOutcome::Unavailable); }; @@ -1144,7 +1200,7 @@ impl<'a> AppBuyerRepository<'a> { return Ok(BuyerRepeatDemandApplyOutcome::Unavailable); } - let current_cart = self.load_buyer_cart(context)?; + let current_cart = self.load_buyer_cart(cart_context)?; if !replace_existing && !current_cart.is_empty() && current_cart.farm_id != Some(candidate.farm_id) @@ -1177,7 +1233,7 @@ impl<'a> AppBuyerRepository<'a> { &candidate.available_lines, replace_existing, )?; - self.replace_buyer_cart(context, &next_cart)?; + self.replace_buyer_cart(cart_context, &next_cart)?; Ok(BuyerRepeatDemandApplyOutcome::Applied) } @@ -1993,23 +2049,29 @@ impl<'a> AppBuyerRepository<'a> { Ok(order_lines) } - fn load_buyer_order_repeat_demand_header( + fn load_buyer_order_repeat_demand_header_for_scope( &self, - context: &BuyerContext, + scope: &SelectedBuyerOrderScope, order_id: OrderId, ) -> Result<Option<(FarmId, String)>, AppSqliteError> { - let context_key = context.storage_key(); - + let context_keys = scope.context_keys(); + if context_keys.is_empty() { + return Ok(None); + } + let placeholders = sql_placeholders(context_keys.len()); + let query = format!( + "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 in ({placeholders}) and o.id = ? + limit 1" + ); + let mut params = context_keys.to_vec(); + params.push(order_id.to_string()); 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)?)), - ) + .query_row(query.as_str(), params_from_iter(params.iter()), |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) .optional() .map_err(|source| AppSqliteError::Query { operation: "load buyer repeat demand header", @@ -2510,17 +2572,6 @@ fn next_buyer_cart_for_repeat_demand( Ok(current_cart) } -fn normalized_buyer_context_keys(context_keys: &[String]) -> Vec<String> { - let mut unique = BTreeSet::new(); - context_keys - .iter() - .map(|key| key.trim()) - .filter(|key| !key.is_empty()) - .filter(|key| unique.insert((*key).to_owned())) - .map(str::to_owned) - .collect() -} - fn sql_placeholders(count: usize) -> String { std::iter::repeat_n("?", count) .collect::<Vec<_>>() @@ -2907,7 +2958,10 @@ mod tests { use rusqlite::{Connection, params}; use serde_json::json; - use crate::{AppSqliteError, AppSqliteStore, BuyerRepeatDemandApplyOutcome, DatabaseTarget}; + use crate::{ + AppSqliteError, AppSqliteStore, BuyerRepeatDemandApplyOutcome, DatabaseTarget, + SelectedBuyerOrderScope, + }; use super::AppBuyerRepository; @@ -3078,17 +3132,13 @@ mod tests { "", ); - let context_keys = vec![ - "account:acct_buyer".to_owned(), - "nostr:buyer-pubkey".to_owned(), - "nostr:buyer-pubkey".to_owned(), - " ".to_owned(), - ]; + let scope = + SelectedBuyerOrderScope::for_selected_account("acct_buyer", Some("buyer-pubkey")); let linked_orders = repository - .load_buyer_orders_for_context_keys(&context_keys) + .load_buyer_orders_for_scope(&scope) .expect("linked buyer orders should load"); let linked_detail = repository - .load_buyer_order_detail_for_context_keys(&context_keys, relay_order_id) + .load_buyer_order_detail_for_scope(&scope, relay_order_id) .expect("linked buyer order detail should load") .expect("linked buyer order detail should exist"); let account_only_orders = repository @@ -3379,6 +3429,120 @@ mod tests { } #[test] + fn buyer_repeat_demand_from_linked_order_writes_selected_account_cart() { + let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); + let connection = store.connection(); + let repository = AppBuyerRepository::new(connection); + let context = BuyerContext::account("acct_buyer"); + 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 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 listing = repository + .load_buyer_product_detail(product_id) + .expect("buyer detail should load") + .expect("listing should exist") + .listing; + + repository + .replace_buyer_cart( + &context, + &radroots_app_view::BuyerCartProjection { + farm_id: Some(farm_id), + farm_display_name: Some("Willow Farm".to_owned()), + lines: vec![radroots_app_view::BuyerCartLineProjection { + product_id: listing.product_id, + farm_id: listing.farm_id, + farm_display_name: listing.farm_display_name.clone(), + title: listing.title.clone(), + quantity: 2, + unit_price: listing.price.clone(), + line_total_minor_units: 1300, + fulfillment_summary: "Friday pickup".to_owned(), + }], + subtotal_minor_units: Some(1300), + currency_code: Some("USD".to_owned()), + replace_confirmation: None, + }, + ) + .expect("buyer cart should save"); + repository + .save_buyer_order_review_draft( + &context, + &radroots_app_view::BuyerOrderReviewDraft { + name: "Casey Buyer".to_owned(), + email: "casey@example.com".to_owned(), + phone: String::new(), + order_note: String::new(), + }, + ) + .expect("buyer order review draft should save"); + let order_id = repository + .place_buyer_order(&context) + .expect("buyer order review should place order"); + + connection + .execute( + "update orders set buyer_context_key = ?1 where id = ?2", + params!["nostr:buyer-pubkey", order_id.to_string()], + ) + .expect("linked order context should mutate"); + + let scope = + SelectedBuyerOrderScope::for_selected_account("acct_buyer", Some("buyer-pubkey")); + let linked_detail = repository + .load_buyer_order_detail_for_scope(&scope, order_id) + .expect("linked detail should load") + .expect("linked order should be visible"); + let account_only_detail = repository + .load_buyer_order_detail(&context, order_id) + .expect("account-only detail should load"); + let outcome = repository + .apply_buyer_repeat_demand_from_scope_to_cart(&scope, &context, order_id, false) + .expect("linked repeat demand should apply"); + let account_cart = repository + .load_buyer_cart(&context) + .expect("selected account cart should load"); + let nostr_cart_line_count: u32 = connection + .query_row( + "select count(*) from buyer_cart_lines where buyer_context_key = ?1", + params!["nostr:buyer-pubkey"], + |row| row.get(0), + ) + .expect("nostr cart line count should load"); + + assert!(linked_detail.repeat_demand.is_some()); + assert!(account_only_detail.is_none()); + assert_eq!(outcome, BuyerRepeatDemandApplyOutcome::Applied); + assert_eq!(account_cart.lines.len(), 1); + assert_eq!(account_cart.lines[0].product_id, product_id); + assert_eq!(account_cart.lines[0].quantity, 2); + assert_eq!(nostr_cart_line_count, 0); + } + + #[test] fn buyer_repeat_demand_requires_current_stock_for_full_historical_quantity() { let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); let connection = store.connection(); diff --git a/crates/store/src/repo/mod.rs b/crates/store/src/repo/mod.rs @@ -21,6 +21,7 @@ pub use activity::{ pub use buyer::{ AppBuyerRepository, BuyerOrderCoordinationRecord, BuyerOrderCoordinationState, BuyerOrderLocalEventExport, BuyerOrderLocalEventLine, BuyerRepeatDemandApplyOutcome, + SelectedBuyerOrderScope, }; pub use farm_rules::{AppFarmRulesRepository, derive_farm_rules_readiness}; pub use farm_setup::AppFarmSetupRepository;