commit ee384d31ee7217eea73246fe046fbf29bb6dd8ac
parent bdfcf0e3485f43f4897f707e348a595e5bd0db15
Author: triesap <tyson@radroots.org>
Date: Thu, 4 Jun 2026 16:24:24 -0700
app: show relay buyer orders for selected account
- add buyer order read APIs that accept explicit context keys
- include linked Nostr context rows in selected account order projections
- keep account-scoped write and single-context read behavior unchanged
- cover account plus linked Nostr order reads in sqlite tests
Diffstat:
3 files changed, 259 insertions(+), 88 deletions(-)
diff --git a/crates/desktop/src/runtime.rs b/crates/desktop/src/runtime.rs
@@ -74,7 +74,7 @@ use radroots_local_events::{
};
use radroots_nostr::prelude::{
RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrFilter, RadrootsNostrOutput,
- RadrootsNostrTimestamp, radroots_nostr_kind,
+ RadrootsNostrTimestamp, radroots_nostr_kind, radroots_nostr_parse_pubkey,
};
use radroots_nostr_accounts::prelude::RadrootsNostrAccountsManager;
use radroots_sdk::farm::{RadrootsFarm, RadrootsFarmRef};
@@ -2021,6 +2021,8 @@ 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 (
refreshed_cart,
refreshed_order_review,
@@ -2033,7 +2035,8 @@ 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_context_keys(&buyer_order_context_keys)?;
let has_recoverable_coordination = !sqlite_store
.load_recoverable_buyer_order_coordination_records(buyer_context)?
.is_empty();
@@ -2053,7 +2056,10 @@ impl DesktopAppRuntimeState {
})
});
let refreshed_order_detail = match detail_order_id {
- Some(order_id) => sqlite_store.load_buyer_order_detail(buyer_context, order_id)?,
+ Some(order_id) => sqlite_store.load_buyer_order_detail_for_context_keys(
+ &buyer_order_context_keys,
+ order_id,
+ )?,
None => None,
};
(
@@ -2096,8 +2102,10 @@ impl DesktopAppRuntimeState {
let Some(sqlite_store) = self.sqlite_store.as_ref() else {
return Ok(false);
};
- let buyer_context = self.state_store.identity_projection().buyer_context();
- let Some(order_detail) = sqlite_store.load_buyer_order_detail(&buyer_context, order_id)?
+ 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)?
else {
return Ok(false);
};
@@ -7964,6 +7972,33 @@ 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);
+ }
+ }
+ context_keys
+}
+
+fn selected_account_public_key_hex(
+ selected_account: &radroots_app_view::SelectedAccountProjection,
+) -> Option<String> {
+ let npub = selected_account.account.npub.trim();
+ radroots_nostr_parse_pubkey(npub)
+ .ok()
+ .map(|public_key| public_key.to_hex())
+ .filter(|public_key| is_hex_64(public_key))
+ .or_else(|| {
+ let account_id = selected_account.account.account_id.trim();
+ is_hex_64(account_id).then(|| account_id.to_owned())
+ })
+}
+
fn load_selected_account_context_with_options(
sqlite_store: &AppSqliteStore,
identity_projection: &AppIdentityProjection,
@@ -7971,6 +8006,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 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();
@@ -7988,12 +8024,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(&buyer_context)?;
+ let buyer_orders =
+ sqlite_store.load_buyer_orders_for_context_keys(&buyer_order_context_keys)?;
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(&buyer_context, order_id)?,
+ Some(order_id) => sqlite_store
+ .load_buyer_order_detail_for_context_keys(&buyer_order_context_keys, order_id)?,
None => None,
};
let personal_projection = PersonalWorkspaceProjection {
diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs
@@ -448,6 +448,14 @@ impl AppSqliteStore {
self.buyer_repository().load_buyer_orders(context)
}
+ pub fn load_buyer_orders_for_context_keys(
+ &self,
+ context_keys: &[String],
+ ) -> Result<BuyerOrdersProjection, AppSqliteError> {
+ self.buyer_repository()
+ .load_buyer_orders_for_context_keys(context_keys)
+ }
+
pub fn load_buyer_order_detail(
&self,
context: &BuyerContext,
@@ -457,6 +465,15 @@ impl AppSqliteStore {
.load_buyer_order_detail(context, order_id)
}
+ pub fn load_buyer_order_detail_for_context_keys(
+ &self,
+ context_keys: &[String],
+ order_id: OrderId,
+ ) -> Result<Option<BuyerOrderDetailProjection>, AppSqliteError> {
+ self.buyer_repository()
+ .load_buyer_order_detail_for_context_keys(context_keys, order_id)
+ }
+
pub fn load_buyer_order_local_event_export(
&self,
context: &BuyerContext,
diff --git a/crates/store/src/repo/buyer.rs b/crates/store/src/repo/buyer.rs
@@ -10,7 +10,7 @@ use radroots_app_view::{
ProductPricePresentation, ProductStatus, ProductStockState, ProductStockSummary,
RepeatDemandEligibility, RepeatDemandHandoffProjection,
};
-use rusqlite::{Connection, OptionalExtension, params};
+use rusqlite::{Connection, OptionalExtension, params, params_from_iter};
use serde_json::Value;
use super::{
@@ -736,40 +736,53 @@ 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))
+ }
+
+ pub fn load_buyer_orders_for_context_keys(
+ &self,
+ context_keys: &[String],
+ ) -> Result<BuyerOrdersProjection, AppSqliteError> {
let now_utc = self.current_utc_timestamp()?;
let visible_listings = self.visible_listing_index(&now_utc)?;
- let context_key = context.storage_key();
- let mut statement = self
- .connection
- .prepare(
- "select
- o.id,
- o.farm_id,
- o.order_number,
- o.status,
- o.workflow_revision,
- o.workflow_agreement,
- o.workflow_fulfillment,
- o.workflow_inventory,
- o.workflow_payment,
- o.workflow_provenance_source,
- o.workflow_provenance_last_event_id,
- f.display_name,
- fw.label,
- fw.starts_at,
- fw.ends_at
- from orders o
- inner join farms f on f.id = o.farm_id
- left join fulfillment_windows fw on fw.id = o.fulfillment_window_id
- where o.buyer_context_key = ?1
- order by o.updated_at desc, o.id desc",
- )
- .map_err(|source| AppSqliteError::Query {
- operation: "prepare buyer orders list",
- source,
- })?;
+ let context_keys = normalized_buyer_context_keys(context_keys);
+ if context_keys.is_empty() {
+ return Ok(BuyerOrdersProjection::default());
+ }
+ let placeholders = sql_placeholders(context_keys.len());
+ let query = format!(
+ "select
+ o.id,
+ o.farm_id,
+ o.order_number,
+ o.status,
+ o.workflow_revision,
+ o.workflow_agreement,
+ o.workflow_fulfillment,
+ o.workflow_inventory,
+ o.workflow_payment,
+ o.workflow_provenance_source,
+ o.workflow_provenance_last_event_id,
+ f.display_name,
+ fw.label,
+ fw.starts_at,
+ fw.ends_at
+ from orders o
+ inner join farms f on f.id = o.farm_id
+ left join fulfillment_windows fw on fw.id = o.fulfillment_window_id
+ where o.buyer_context_key in ({placeholders})
+ order by o.updated_at desc, o.id desc"
+ );
+ let mut statement =
+ self.connection
+ .prepare(query.as_str())
+ .map_err(|source| AppSqliteError::Query {
+ operation: "prepare buyer orders list",
+ source,
+ })?;
let rows = statement
- .query_map(params![context_key.as_str()], |row| {
+ .query_map(params_from_iter(context_keys.iter()), |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
@@ -863,56 +876,70 @@ 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)
+ }
+
+ pub fn load_buyer_order_detail_for_context_keys(
+ &self,
+ context_keys: &[String],
+ 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_key = context.storage_key();
+ let context_keys = normalized_buyer_context_keys(context_keys);
+ if context_keys.is_empty() {
+ return Ok(None);
+ }
+ let placeholders = sql_placeholders(context_keys.len());
+ let query = format!(
+ "select
+ o.id,
+ o.farm_id,
+ o.order_number,
+ o.status,
+ o.buyer_order_note,
+ o.workflow_revision,
+ o.workflow_agreement,
+ o.workflow_fulfillment,
+ o.workflow_inventory,
+ o.workflow_payment,
+ o.workflow_provenance_source,
+ o.workflow_provenance_last_event_id,
+ f.display_name,
+ fw.label,
+ fw.starts_at,
+ fw.ends_at
+ from orders o
+ inner join farms f on f.id = o.farm_id
+ left join fulfillment_windows fw on fw.id = o.fulfillment_window_id
+ where o.buyer_context_key in ({placeholders}) and o.id = ?
+ limit 1"
+ );
+ let mut params = context_keys.clone();
+ params.push(order_id.to_string());
let record = self
.connection
- .query_row(
- "select
- o.id,
- o.farm_id,
- o.order_number,
- o.status,
- o.buyer_order_note,
- o.workflow_revision,
- o.workflow_agreement,
- o.workflow_fulfillment,
- o.workflow_inventory,
- o.workflow_payment,
- o.workflow_provenance_source,
- o.workflow_provenance_last_event_id,
- f.display_name,
- fw.label,
- fw.starts_at,
- fw.ends_at
- from orders o
- inner join farms f on f.id = o.farm_id
- left join fulfillment_windows fw on fw.id = o.fulfillment_window_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)?,
- row.get::<_, String>(2)?,
- row.get::<_, String>(3)?,
- row.get::<_, String>(4)?,
- row.get::<_, String>(5)?,
- row.get::<_, String>(6)?,
- row.get::<_, Option<String>>(7)?,
- row.get::<_, String>(8)?,
- row.get::<_, String>(9)?,
- row.get::<_, String>(10)?,
- row.get::<_, Option<String>>(11)?,
- row.get::<_, String>(12)?,
- row.get::<_, Option<String>>(13)?,
- row.get::<_, Option<String>>(14)?,
- row.get::<_, Option<String>>(15)?,
- ))
- },
- )
+ .query_row(query.as_str(), params_from_iter(params.iter()), |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, String>(1)?,
+ row.get::<_, String>(2)?,
+ row.get::<_, String>(3)?,
+ row.get::<_, String>(4)?,
+ row.get::<_, String>(5)?,
+ row.get::<_, String>(6)?,
+ row.get::<_, Option<String>>(7)?,
+ row.get::<_, String>(8)?,
+ row.get::<_, String>(9)?,
+ row.get::<_, String>(10)?,
+ row.get::<_, Option<String>>(11)?,
+ row.get::<_, String>(12)?,
+ row.get::<_, Option<String>>(13)?,
+ row.get::<_, Option<String>>(14)?,
+ row.get::<_, Option<String>>(15)?,
+ ))
+ })
.optional()
.map_err(|source| AppSqliteError::Query {
operation: "load buyer order detail",
@@ -2483,6 +2510,23 @@ 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<_>>()
+ .join(", ")
+}
+
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 {
@@ -2855,9 +2899,10 @@ mod tests {
use std::collections::BTreeSet;
use radroots_app_view::{
- BuyerContext, BuyerOrderReviewDisabledReason, FarmId, FarmOrderMethod, FulfillmentWindowId,
- OrderId, PickupLocationId, ProductId, TradeAgreementStatus, TradeFulfillmentStatus,
- TradeInventoryStatus, TradePaymentDisplayStatus, TradeRevisionStatus, TradeWorkflowSource,
+ BuyerContext, BuyerOrderReviewDisabledReason, BuyerOrderStatus, FarmId, FarmOrderMethod,
+ FulfillmentWindowId, OrderId, PickupLocationId, ProductId, TradeAgreementStatus,
+ TradeFulfillmentStatus, TradeInventoryStatus, TradePaymentDisplayStatus,
+ TradeRevisionStatus, TradeWorkflowSource,
};
use rusqlite::{Connection, params};
use serde_json::json;
@@ -3003,6 +3048,77 @@ mod tests {
}
#[test]
+ fn buyer_orders_can_read_account_and_linked_nostr_contexts() {
+ let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
+ let connection = store.connection();
+ let repository = AppBuyerRepository::new(connection);
+ let farm_id = insert_farm(connection, "Willow Farm", "ready");
+ let account_order_id = OrderId::new();
+ let relay_order_id = OrderId::new();
+ insert_order(
+ connection,
+ account_order_id,
+ farm_id,
+ "1001",
+ "scheduled",
+ Some("account:acct_buyer"),
+ "casey@example.com",
+ "555-0100",
+ "",
+ );
+ insert_order(
+ connection,
+ relay_order_id,
+ farm_id,
+ "1002",
+ "packed",
+ Some("nostr:buyer-pubkey"),
+ "",
+ "",
+ "",
+ );
+
+ let context_keys = vec![
+ "account:acct_buyer".to_owned(),
+ "nostr:buyer-pubkey".to_owned(),
+ "nostr:buyer-pubkey".to_owned(),
+ " ".to_owned(),
+ ];
+ let linked_orders = repository
+ .load_buyer_orders_for_context_keys(&context_keys)
+ .expect("linked buyer orders should load");
+ let linked_detail = repository
+ .load_buyer_order_detail_for_context_keys(&context_keys, relay_order_id)
+ .expect("linked buyer order detail should load")
+ .expect("linked buyer order detail should exist");
+ let account_only_orders = repository
+ .load_buyer_orders(&BuyerContext::account("acct_buyer"))
+ .expect("account buyer orders should load");
+ let account_only_detail = repository
+ .load_buyer_order_detail(&BuyerContext::account("acct_buyer"), relay_order_id)
+ .expect("account buyer order detail should load");
+
+ assert_eq!(linked_orders.rows.len(), 2);
+ assert!(
+ linked_orders
+ .rows
+ .iter()
+ .any(|row| row.order_id == account_order_id)
+ );
+ assert!(
+ linked_orders
+ .rows
+ .iter()
+ .any(|row| row.order_id == relay_order_id)
+ );
+ assert_eq!(linked_detail.order_id, relay_order_id);
+ assert_eq!(linked_detail.status, BuyerOrderStatus::Ready);
+ assert_eq!(account_only_orders.rows.len(), 1);
+ assert_eq!(account_only_orders.rows[0].order_id, account_order_id);
+ assert!(account_only_detail.is_none());
+ }
+
+ #[test]
fn buyer_order_review_requires_account_before_order_write() {
let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
let connection = store.connection();