lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 996ab7fabc64c1f67f98122bee940fb487d7cbc1
parent e642ae75a82aeca66379a8134ccb3db1d3f3e615
Author: triesap <tyson@radroots.org>
Date:   Sat, 28 Mar 2026 00:06:16 +0000

trade: add marketplace read-index queries

Diffstat:
Mcrates/trade/src/listing/projection.rs | 969++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 968 insertions(+), 1 deletion(-)

diff --git a/crates/trade/src/listing/projection.rs b/crates/trade/src/listing/projection.rs @@ -1,5 +1,7 @@ #![forbid(unsafe_code)] +use core::cmp::Ordering; + #[cfg(not(feature = "std"))] use alloc::{collections::BTreeMap, format, string::String, vec::Vec}; #[cfg(feature = "std")] @@ -172,6 +174,222 @@ pub struct RadrootsTradeOrderWorkflowMessage { pub payload: TradeListingMessagePayload, } +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadrootsTradeSortDirection { + Asc, + Desc, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsTradeListingMarketStatus { + Unknown, + Window, + Active, + Sold, + Other { value: String }, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RadrootsTradeListingQuery { + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub seller_pubkey: Option<String>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub farm_pubkey: Option<String>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub farm_id: Option<String>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub product_key: Option<String>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub product_category: Option<String>, + #[cfg_attr( + feature = "ts-rs", + ts(optional, type = "RadrootsTradeListingMarketStatus | null") + )] + pub listing_status: Option<RadrootsTradeListingMarketStatus>, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadrootsTradeListingSortField { + ListingAddr, + ProductTitle, + ProductCategory, + SellerPubkey, + InventoryAvailable, + OpenOrderCount, + TotalOrderCount, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RadrootsTradeListingSort { + pub field: RadrootsTradeListingSortField, + pub direction: RadrootsTradeSortDirection, +} + +impl Default for RadrootsTradeListingSort { + fn default() -> Self { + Self { + field: RadrootsTradeListingSortField::ListingAddr, + direction: RadrootsTradeSortDirection::Asc, + } + } +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RadrootsTradeOrderQuery { + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub listing_addr: Option<String>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub buyer_pubkey: Option<String>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub seller_pubkey: Option<String>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "TradeOrderStatus | null"))] + pub status: Option<TradeOrderStatus>, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadrootsTradeOrderSortField { + OrderId, + ListingAddr, + BuyerPubkey, + SellerPubkey, + Status, + LastMessageType, + TotalBinCount, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RadrootsTradeOrderSort { + pub field: RadrootsTradeOrderSortField, + pub direction: RadrootsTradeSortDirection, +} + +impl Default for RadrootsTradeOrderSort { + fn default() -> Self { + Self { + field: RadrootsTradeOrderSortField::OrderId, + direction: RadrootsTradeSortDirection::Asc, + } + } +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsTradeFacetCount { + pub key: String, + pub count: u32, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsTradeListingFacets { + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsTradeFacetCount[]"))] + pub seller_pubkeys: Vec<RadrootsTradeFacetCount>, + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsTradeFacetCount[]"))] + pub farm_pubkeys: Vec<RadrootsTradeFacetCount>, + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsTradeFacetCount[]"))] + pub farm_ids: Vec<RadrootsTradeFacetCount>, + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsTradeFacetCount[]"))] + pub product_keys: Vec<RadrootsTradeFacetCount>, + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsTradeFacetCount[]"))] + pub product_categories: Vec<RadrootsTradeFacetCount>, + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsTradeFacetCount[]"))] + pub listing_statuses: Vec<RadrootsTradeFacetCount>, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsTradeOrderFacets { + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsTradeFacetCount[]"))] + pub buyer_pubkeys: Vec<RadrootsTradeFacetCount>, + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsTradeFacetCount[]"))] + pub seller_pubkeys: Vec<RadrootsTradeFacetCount>, + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsTradeFacetCount[]"))] + pub listing_addrs: Vec<RadrootsTradeFacetCount>, + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsTradeFacetCount[]"))] + pub statuses: Vec<RadrootsTradeFacetCount>, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsTradeMarketplaceListingSummary { + pub listing_addr: String, + pub seller_pubkey: String, + pub farm_pubkey: String, + pub farm_id: String, + pub product_key: String, + pub product_title: String, + pub product_category: String, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub product_summary: Option<String>, + pub listing_status: RadrootsTradeListingMarketStatus, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub location_primary: Option<String>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "RadrootsCoreDecimal | null"))] + pub inventory_available: Option<RadrootsCoreDecimal>, + pub primary_bin_id: String, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub primary_bin_label: Option<String>, + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsTradeListingTotal"))] + pub primary_bin_total: RadrootsTradeListingTotal, + pub order_count: u32, + pub open_order_count: u32, + pub terminal_order_count: u32, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsTradeMarketplaceOrderSummary { + pub order_id: String, + pub listing_addr: String, + pub buyer_pubkey: String, + pub seller_pubkey: String, + pub status: TradeOrderStatus, + pub last_message_type: TradeListingMessageType, + pub item_count: u32, + pub total_bin_count: u32, + pub has_requested_discounts: bool, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub last_reason: Option<String>, +} + #[derive(Clone, Debug, Default)] pub struct RadrootsTradeReadIndex { listings: BTreeMap<String, RadrootsTradeListingProjection>, @@ -261,6 +479,60 @@ impl std::error::Error for RadrootsTradeProjectionError { } impl RadrootsTradeListingProjection { + pub fn market_status(&self) -> RadrootsTradeListingMarketStatus { + match &self.availability { + Some(RadrootsListingAvailability::Status { status }) => match status { + radroots_events::listing::RadrootsListingStatus::Active => { + RadrootsTradeListingMarketStatus::Active + } + radroots_events::listing::RadrootsListingStatus::Sold => { + RadrootsTradeListingMarketStatus::Sold + } + radroots_events::listing::RadrootsListingStatus::Other { value } => { + RadrootsTradeListingMarketStatus::Other { + value: value.clone(), + } + } + }, + Some(RadrootsListingAvailability::Window { .. }) => { + RadrootsTradeListingMarketStatus::Window + } + None => RadrootsTradeListingMarketStatus::Unknown, + } + } + + pub fn primary_bin(&self) -> Option<&RadrootsTradeListingBinProjection> { + self.bins + .iter() + .find(|bin| bin.bin.bin_id == self.primary_bin_id) + } + + pub fn marketplace_summary(&self) -> Option<RadrootsTradeMarketplaceListingSummary> { + let primary_bin = self.primary_bin()?; + Some(RadrootsTradeMarketplaceListingSummary { + listing_addr: self.listing_addr.clone(), + seller_pubkey: self.seller_pubkey.clone(), + farm_pubkey: self.farm.pubkey.clone(), + farm_id: self.farm.d_tag.clone(), + product_key: self.product.key.clone(), + product_title: self.product.title.clone(), + product_category: self.product.category.clone(), + product_summary: self.product.summary.clone(), + listing_status: self.market_status(), + location_primary: self + .location + .as_ref() + .map(|location| location.primary.clone()), + inventory_available: self.inventory_available.clone(), + primary_bin_id: self.primary_bin_id.clone(), + primary_bin_label: primary_bin.bin.display_label.clone(), + primary_bin_total: primary_bin.one_bin_total.clone(), + order_count: self.order_count, + open_order_count: self.open_order_count, + terminal_order_count: self.terminal_order_count, + }) + } + pub fn from_listing_event( event: &RadrootsNostrEvent, ) -> Result<Self, RadrootsTradeProjectionError> { @@ -325,6 +597,34 @@ impl RadrootsTradeOrderWorkflowProjection { radroots_trade_order_status_is_terminal(&self.status) } + pub fn item_count(&self) -> u32 { + u32::try_from(self.items.len()).unwrap_or(u32::MAX) + } + + pub fn total_bin_count(&self) -> u32 { + self.items + .iter() + .fold(0u32, |total, item| total.saturating_add(item.bin_count)) + } + + pub fn marketplace_summary(&self) -> RadrootsTradeMarketplaceOrderSummary { + RadrootsTradeMarketplaceOrderSummary { + order_id: self.order_id.clone(), + listing_addr: self.listing_addr.clone(), + buyer_pubkey: self.buyer_pubkey.clone(), + seller_pubkey: self.seller_pubkey.clone(), + status: self.status.clone(), + last_message_type: self.last_message_type, + item_count: self.item_count(), + total_bin_count: self.total_bin_count(), + has_requested_discounts: self + .requested_discounts + .as_ref() + .is_some_and(|discounts| !discounts.is_empty()), + last_reason: self.last_reason.clone(), + } + } + fn from_order_request(order: &TradeOrder) -> Self { Self { order_id: order.order_id.clone(), @@ -430,6 +730,112 @@ impl RadrootsTradeReadIndex { self.orders.get(order_id) } + pub fn query_listings<'a>( + &'a self, + query: &RadrootsTradeListingQuery, + sort: RadrootsTradeListingSort, + ) -> Vec<&'a RadrootsTradeListingProjection> { + let mut listings = self + .listings + .values() + .filter(|listing| listing_matches_query(listing, query)) + .collect::<Vec<_>>(); + listings.sort_by(|left, right| compare_listings(left, right, sort)); + listings + } + + pub fn query_orders<'a>( + &'a self, + query: &RadrootsTradeOrderQuery, + sort: RadrootsTradeOrderSort, + ) -> Vec<&'a RadrootsTradeOrderWorkflowProjection> { + let mut orders = self + .orders + .values() + .filter(|order| order_matches_query(order, query)) + .collect::<Vec<_>>(); + orders.sort_by(|left, right| compare_orders(left, right, sort)); + orders + } + + pub fn listing_facets(&self, query: &RadrootsTradeListingQuery) -> RadrootsTradeListingFacets { + let mut seller_pubkeys = BTreeMap::<String, u32>::new(); + let mut farm_pubkeys = BTreeMap::<String, u32>::new(); + let mut farm_ids = BTreeMap::<String, u32>::new(); + let mut product_keys = BTreeMap::<String, u32>::new(); + let mut product_categories = BTreeMap::<String, u32>::new(); + let mut listing_statuses = BTreeMap::<String, u32>::new(); + + for listing in self + .listings + .values() + .filter(|listing| listing_matches_query(listing, query)) + { + increment_count(&mut seller_pubkeys, listing.seller_pubkey.clone()); + increment_count(&mut farm_pubkeys, listing.farm.pubkey.clone()); + increment_count(&mut farm_ids, listing.farm.d_tag.clone()); + increment_count(&mut product_keys, listing.product.key.clone()); + increment_count(&mut product_categories, listing.product.category.clone()); + increment_count(&mut listing_statuses, listing.market_status().facet_key()); + } + + RadrootsTradeListingFacets { + seller_pubkeys: facet_counts_from_map(seller_pubkeys), + farm_pubkeys: facet_counts_from_map(farm_pubkeys), + farm_ids: facet_counts_from_map(farm_ids), + product_keys: facet_counts_from_map(product_keys), + product_categories: facet_counts_from_map(product_categories), + listing_statuses: facet_counts_from_map(listing_statuses), + } + } + + pub fn order_facets(&self, query: &RadrootsTradeOrderQuery) -> RadrootsTradeOrderFacets { + let mut buyer_pubkeys = BTreeMap::<String, u32>::new(); + let mut seller_pubkeys = BTreeMap::<String, u32>::new(); + let mut listing_addrs = BTreeMap::<String, u32>::new(); + let mut statuses = BTreeMap::<String, u32>::new(); + + for order in self + .orders + .values() + .filter(|order| order_matches_query(order, query)) + { + increment_count(&mut buyer_pubkeys, order.buyer_pubkey.clone()); + increment_count(&mut seller_pubkeys, order.seller_pubkey.clone()); + increment_count(&mut listing_addrs, order.listing_addr.clone()); + increment_count(&mut statuses, order_status_key(&order.status)); + } + + RadrootsTradeOrderFacets { + buyer_pubkeys: facet_counts_from_map(buyer_pubkeys), + seller_pubkeys: facet_counts_from_map(seller_pubkeys), + listing_addrs: facet_counts_from_map(listing_addrs), + statuses: facet_counts_from_map(statuses), + } + } + + pub fn marketplace_listing_summaries( + &self, + query: &RadrootsTradeListingQuery, + sort: RadrootsTradeListingSort, + ) -> Vec<RadrootsTradeMarketplaceListingSummary> { + self.query_listings(query, sort) + .into_iter() + .filter_map(|listing| listing.marketplace_summary()) + .collect() + } + + pub fn marketplace_order_summaries( + &self, + query: &RadrootsTradeOrderQuery, + sort: RadrootsTradeOrderSort, + ) -> Vec<RadrootsTradeMarketplaceOrderSummary> { + self.query_orders(query, sort) + .into_iter() + .map(RadrootsTradeOrderWorkflowProjection::marketplace_summary) + .collect() + } + pub fn upsert_listing( &mut self, seller_pubkey: impl Into<String>, @@ -952,10 +1358,253 @@ fn trade_discount_decision_value( } } +impl RadrootsTradeListingMarketStatus { + fn facet_key(&self) -> String { + match self { + Self::Unknown => "unknown".into(), + Self::Window => "window".into(), + Self::Active => "active".into(), + Self::Sold => "sold".into(), + Self::Other { value } => value.clone(), + } + } +} + +fn order_status_key(status: &TradeOrderStatus) -> String { + match status { + TradeOrderStatus::Draft => "draft".into(), + TradeOrderStatus::Validated => "validated".into(), + TradeOrderStatus::Requested => "requested".into(), + TradeOrderStatus::Questioned => "questioned".into(), + TradeOrderStatus::Revised => "revised".into(), + TradeOrderStatus::Accepted => "accepted".into(), + TradeOrderStatus::Declined => "declined".into(), + TradeOrderStatus::Cancelled => "cancelled".into(), + TradeOrderStatus::Fulfilled => "fulfilled".into(), + TradeOrderStatus::Completed => "completed".into(), + } +} + +fn message_type_key(message_type: TradeListingMessageType) -> &'static str { + match message_type { + TradeListingMessageType::ListingValidateRequest => "listing_validate_request", + TradeListingMessageType::ListingValidateResult => "listing_validate_result", + TradeListingMessageType::OrderRequest => "order_request", + TradeListingMessageType::OrderResponse => "order_response", + TradeListingMessageType::OrderRevision => "order_revision", + TradeListingMessageType::OrderRevisionAccept => "order_revision_accept", + TradeListingMessageType::OrderRevisionDecline => "order_revision_decline", + TradeListingMessageType::Question => "question", + TradeListingMessageType::Answer => "answer", + TradeListingMessageType::DiscountRequest => "discount_request", + TradeListingMessageType::DiscountOffer => "discount_offer", + TradeListingMessageType::DiscountAccept => "discount_accept", + TradeListingMessageType::DiscountDecline => "discount_decline", + TradeListingMessageType::Cancel => "cancel", + TradeListingMessageType::FulfillmentUpdate => "fulfillment_update", + TradeListingMessageType::Receipt => "receipt", + } +} + +fn compare_direction(ordering: Ordering, direction: RadrootsTradeSortDirection) -> Ordering { + match direction { + RadrootsTradeSortDirection::Asc => ordering, + RadrootsTradeSortDirection::Desc => ordering.reverse(), + } +} + +fn compare_option_decimal( + left: &Option<RadrootsCoreDecimal>, + right: &Option<RadrootsCoreDecimal>, +) -> Ordering { + match (left, right) { + (Some(left), Some(right)) => left.partial_cmp(right).unwrap_or(Ordering::Equal), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => Ordering::Equal, + } +} + +fn compare_listings( + left: &RadrootsTradeListingProjection, + right: &RadrootsTradeListingProjection, + sort: RadrootsTradeListingSort, +) -> Ordering { + let ordering = match sort.field { + RadrootsTradeListingSortField::ListingAddr => left.listing_addr.cmp(&right.listing_addr), + RadrootsTradeListingSortField::ProductTitle => left + .product + .title + .cmp(&right.product.title) + .then_with(|| left.listing_addr.cmp(&right.listing_addr)), + RadrootsTradeListingSortField::ProductCategory => left + .product + .category + .cmp(&right.product.category) + .then_with(|| left.listing_addr.cmp(&right.listing_addr)), + RadrootsTradeListingSortField::SellerPubkey => left + .seller_pubkey + .cmp(&right.seller_pubkey) + .then_with(|| left.listing_addr.cmp(&right.listing_addr)), + RadrootsTradeListingSortField::InventoryAvailable => { + compare_option_decimal(&left.inventory_available, &right.inventory_available) + .then_with(|| left.listing_addr.cmp(&right.listing_addr)) + } + RadrootsTradeListingSortField::OpenOrderCount => left + .open_order_count + .cmp(&right.open_order_count) + .then_with(|| left.listing_addr.cmp(&right.listing_addr)), + RadrootsTradeListingSortField::TotalOrderCount => left + .order_count + .cmp(&right.order_count) + .then_with(|| left.listing_addr.cmp(&right.listing_addr)), + }; + compare_direction(ordering, sort.direction) +} + +fn compare_orders( + left: &RadrootsTradeOrderWorkflowProjection, + right: &RadrootsTradeOrderWorkflowProjection, + sort: RadrootsTradeOrderSort, +) -> Ordering { + let ordering = match sort.field { + RadrootsTradeOrderSortField::OrderId => left.order_id.cmp(&right.order_id), + RadrootsTradeOrderSortField::ListingAddr => left + .listing_addr + .cmp(&right.listing_addr) + .then_with(|| left.order_id.cmp(&right.order_id)), + RadrootsTradeOrderSortField::BuyerPubkey => left + .buyer_pubkey + .cmp(&right.buyer_pubkey) + .then_with(|| left.order_id.cmp(&right.order_id)), + RadrootsTradeOrderSortField::SellerPubkey => left + .seller_pubkey + .cmp(&right.seller_pubkey) + .then_with(|| left.order_id.cmp(&right.order_id)), + RadrootsTradeOrderSortField::Status => order_status_key(&left.status) + .cmp(&order_status_key(&right.status)) + .then_with(|| left.order_id.cmp(&right.order_id)), + RadrootsTradeOrderSortField::LastMessageType => message_type_key(left.last_message_type) + .cmp(message_type_key(right.last_message_type)) + .then_with(|| left.order_id.cmp(&right.order_id)), + RadrootsTradeOrderSortField::TotalBinCount => left + .total_bin_count() + .cmp(&right.total_bin_count()) + .then_with(|| left.order_id.cmp(&right.order_id)), + }; + compare_direction(ordering, sort.direction) +} + +fn listing_matches_query( + listing: &RadrootsTradeListingProjection, + query: &RadrootsTradeListingQuery, +) -> bool { + if query + .seller_pubkey + .as_deref() + .is_some_and(|value| value != listing.seller_pubkey) + { + return false; + } + if query + .farm_pubkey + .as_deref() + .is_some_and(|value| value != listing.farm.pubkey) + { + return false; + } + if query + .farm_id + .as_deref() + .is_some_and(|value| value != listing.farm.d_tag) + { + return false; + } + if query + .product_key + .as_deref() + .is_some_and(|value| value != listing.product.key) + { + return false; + } + if query + .product_category + .as_deref() + .is_some_and(|value| value != listing.product.category) + { + return false; + } + if query + .listing_status + .as_ref() + .is_some_and(|value| value != &listing.market_status()) + { + return false; + } + true +} + +fn order_matches_query( + order: &RadrootsTradeOrderWorkflowProjection, + query: &RadrootsTradeOrderQuery, +) -> bool { + if query + .listing_addr + .as_deref() + .is_some_and(|value| value != order.listing_addr) + { + return false; + } + if query + .buyer_pubkey + .as_deref() + .is_some_and(|value| value != order.buyer_pubkey) + { + return false; + } + if query + .seller_pubkey + .as_deref() + .is_some_and(|value| value != order.seller_pubkey) + { + return false; + } + if query + .status + .as_ref() + .is_some_and(|value| value != &order.status) + { + return false; + } + true +} + +fn increment_count(counts: &mut BTreeMap<String, u32>, key: String) { + let count = counts.entry(key).or_insert(0); + *count = count.saturating_add(1); +} + +fn facet_counts_from_map(counts: BTreeMap<String, u32>) -> Vec<RadrootsTradeFacetCount> { + let mut values = counts + .into_iter() + .map(|(key, count)| RadrootsTradeFacetCount { key, count }) + .collect::<Vec<_>>(); + values.sort_by(|left, right| { + right + .count + .cmp(&left.count) + .then_with(|| left.key.cmp(&right.key)) + }); + values +} + #[cfg(test)] mod tests { use super::{ - RadrootsTradeOrderWorkflowMessage, RadrootsTradeProjectionError, RadrootsTradeReadIndex, + RadrootsTradeListingMarketStatus, RadrootsTradeListingQuery, RadrootsTradeListingSort, + RadrootsTradeListingSortField, RadrootsTradeOrderQuery, RadrootsTradeOrderSort, + RadrootsTradeOrderSortField, RadrootsTradeOrderWorkflowMessage, + RadrootsTradeProjectionError, RadrootsTradeReadIndex, RadrootsTradeSortDirection, radroots_trade_order_status_can_transition, radroots_trade_order_status_is_terminal, }; use crate::listing::{ @@ -1088,6 +1737,94 @@ mod tests { } } + fn alternate_listing() -> RadrootsListing { + RadrootsListing { + d_tag: "AAAAAAAAAAAAAAAAAAAAAw".into(), + farm: RadrootsListingFarmRef { + pubkey: "farm-pubkey-2".into(), + d_tag: "AAAAAAAAAAAAAAAAAAAABA".into(), + }, + product: RadrootsListingProduct { + key: "greens".into(), + title: "Greens".into(), + category: "vegetables".into(), + summary: Some("washed bunches".into()), + process: None, + lot: None, + location: None, + profile: None, + year: None, + }, + primary_bin_id: "bin-1".into(), + bins: vec![RadrootsListingBin { + bin_id: "bin-1".into(), + quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(500u32), + RadrootsCoreUnit::MassG, + ), + price_per_canonical_unit: RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(4u32), + RadrootsCoreCurrency::USD, + ), + RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1u32), + RadrootsCoreUnit::MassG, + ), + ), + display_amount: None, + display_unit: None, + display_label: Some("500g bunch".into()), + display_price: Some(RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(2000u32), + RadrootsCoreCurrency::USD, + )), + display_price_unit: Some(RadrootsCoreUnit::Each), + }], + resource_area: None, + plot: None, + discounts: None, + inventory_available: Some(RadrootsCoreDecimal::from(4u32)), + availability: Some(RadrootsListingAvailability::Window { + start: Some(1_700_000_000), + end: Some(1_800_000_000), + }), + delivery_method: Some(RadrootsListingDeliveryMethod::Shipping), + location: Some(RadrootsListingLocation { + primary: "warehouse".into(), + city: Some("Louisville".into()), + region: Some("KY".into()), + country: Some("US".into()), + lat: None, + lng: None, + geohash: None, + }), + images: None, + } + } + + fn alternate_order() -> TradeOrder { + TradeOrder { + order_id: "order-2".into(), + listing_addr: "30402:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAw".into(), + buyer_pubkey: "buyer-pubkey-2".into(), + seller_pubkey: "seller-pubkey".into(), + items: vec![ + TradeOrderItem { + bin_id: "bin-1".into(), + bin_count: 3, + }, + TradeOrderItem { + bin_id: "bin-2".into(), + bin_count: 1, + }, + ], + discounts: None, + notes: Some("expedite".into()), + status: TradeOrderStatus::Draft, + } + } + fn message( actor_pubkey: &str, listing_addr: &str, @@ -1525,6 +2262,236 @@ mod tests { } #[test] + fn listing_query_helpers_filter_sort_and_facet_marketplace_views() { + let mut index = RadrootsTradeReadIndex::new(); + index + .upsert_listing("seller-pubkey", &base_listing()) + .expect("base listing"); + index + .upsert_listing("seller-pubkey", &alternate_listing()) + .expect("alternate listing"); + + index + .apply_workflow_message(&message( + "buyer-pubkey", + "30402:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAg", + Some("order-1"), + TradeListingMessagePayload::OrderRequest(base_order()), + )) + .expect("open order"); + index + .apply_workflow_message(&message( + "buyer-pubkey-2", + "30402:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAw", + Some("order-2"), + TradeListingMessagePayload::OrderRequest(alternate_order()), + )) + .expect("second order request"); + index + .apply_workflow_message(&message( + "seller-pubkey", + "30402:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAw", + Some("order-2"), + TradeListingMessagePayload::OrderResponse(TradeOrderResponse { + accepted: true, + reason: None, + }), + )) + .expect("order accepted"); + index + .apply_workflow_message(&message( + "seller-pubkey", + "30402:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAw", + Some("order-2"), + TradeListingMessagePayload::FulfillmentUpdate(TradeFulfillmentUpdate { + status: TradeFulfillmentStatus::Delivered, + tracking: None, + eta: None, + notes: Some("shipped".into()), + }), + )) + .expect("order fulfilled"); + index + .apply_workflow_message(&message( + "buyer-pubkey-2", + "30402:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAw", + Some("order-2"), + TradeListingMessagePayload::Receipt(TradeReceipt { + acknowledged: true, + at: 1_700_000_010, + note: Some("received".into()), + }), + )) + .expect("order receipt"); + + let coffee_query = RadrootsTradeListingQuery { + product_category: Some("coffee".into()), + ..Default::default() + }; + let coffee_results = index.query_listings( + &coffee_query, + RadrootsTradeListingSort { + field: RadrootsTradeListingSortField::ProductTitle, + direction: RadrootsTradeSortDirection::Asc, + }, + ); + assert_eq!(coffee_results.len(), 1); + assert_eq!(coffee_results[0].listing_id, "AAAAAAAAAAAAAAAAAAAAAg"); + + let listing_summaries = index.marketplace_listing_summaries( + &RadrootsTradeListingQuery::default(), + RadrootsTradeListingSort { + field: RadrootsTradeListingSortField::OpenOrderCount, + direction: RadrootsTradeSortDirection::Desc, + }, + ); + assert_eq!(listing_summaries.len(), 2); + assert_eq!(listing_summaries[0].listing_addr, base_order().listing_addr); + assert_eq!(listing_summaries[0].open_order_count, 1); + assert_eq!( + listing_summaries[0].primary_bin_label.as_deref(), + Some("1kg bag") + ); + assert_eq!( + listing_summaries[1].listing_status, + RadrootsTradeListingMarketStatus::Window + ); + assert_eq!( + listing_summaries[1].location_primary.as_deref(), + Some("warehouse") + ); + + let facets = index.listing_facets(&RadrootsTradeListingQuery::default()); + assert_eq!(facets.farm_pubkeys.len(), 2); + assert_eq!(facets.product_categories.len(), 2); + assert_eq!( + facets + .listing_statuses + .iter() + .map(|facet| (facet.key.as_str(), facet.count)) + .collect::<Vec<_>>(), + vec![("active", 1), ("window", 1)] + ); + } + + #[test] + fn order_query_helpers_filter_sort_and_facet_marketplace_views() { + let mut index = RadrootsTradeReadIndex::new(); + index + .upsert_listing("seller-pubkey", &base_listing()) + .expect("base listing"); + index + .upsert_listing("seller-pubkey", &alternate_listing()) + .expect("alternate listing"); + + index + .apply_workflow_message(&message( + "buyer-pubkey", + "30402:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAg", + Some("order-1"), + TradeListingMessagePayload::OrderRequest(base_order()), + )) + .expect("first order"); + index + .apply_workflow_message(&message( + "buyer-pubkey-2", + "30402:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAw", + Some("order-2"), + TradeListingMessagePayload::OrderRequest(alternate_order()), + )) + .expect("second order"); + index + .apply_workflow_message(&message( + "seller-pubkey", + "30402:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAw", + Some("order-2"), + TradeListingMessagePayload::OrderResponse(TradeOrderResponse { + accepted: true, + reason: Some("approved".into()), + }), + )) + .expect("accepted"); + index + .apply_workflow_message(&message( + "seller-pubkey", + "30402:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAw", + Some("order-2"), + TradeListingMessagePayload::FulfillmentUpdate(TradeFulfillmentUpdate { + status: TradeFulfillmentStatus::Delivered, + tracking: Some("track-2".into()), + eta: None, + notes: Some("in transit".into()), + }), + )) + .expect("fulfilled"); + index + .apply_workflow_message(&message( + "buyer-pubkey-2", + "30402:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAw", + Some("order-2"), + TradeListingMessagePayload::Receipt(TradeReceipt { + acknowledged: true, + at: 1_700_000_020, + note: Some("all good".into()), + }), + )) + .expect("completed"); + + let completed_query = RadrootsTradeOrderQuery { + seller_pubkey: Some("seller-pubkey".into()), + status: Some(TradeOrderStatus::Completed), + ..Default::default() + }; + let completed_orders = index.query_orders( + &completed_query, + RadrootsTradeOrderSort { + field: RadrootsTradeOrderSortField::TotalBinCount, + direction: RadrootsTradeSortDirection::Desc, + }, + ); + assert_eq!(completed_orders.len(), 1); + assert_eq!(completed_orders[0].order_id, "order-2"); + assert_eq!(completed_orders[0].total_bin_count(), 4); + + let summaries = index.marketplace_order_summaries( + &RadrootsTradeOrderQuery { + seller_pubkey: Some("seller-pubkey".into()), + ..Default::default() + }, + RadrootsTradeOrderSort { + field: RadrootsTradeOrderSortField::TotalBinCount, + direction: RadrootsTradeSortDirection::Desc, + }, + ); + assert_eq!(summaries.len(), 2); + assert_eq!(summaries[0].order_id, "order-2"); + assert_eq!(summaries[0].item_count, 2); + assert_eq!(summaries[0].total_bin_count, 4); + assert!(!summaries[0].has_requested_discounts); + assert_eq!(summaries[0].last_reason.as_deref(), Some("all good")); + assert_eq!(summaries[1].order_id, "order-1"); + assert!(summaries[1].has_requested_discounts); + + let facets = index.order_facets(&RadrootsTradeOrderQuery::default()); + assert_eq!( + facets + .statuses + .iter() + .map(|facet| (facet.key.as_str(), facet.count)) + .collect::<Vec<_>>(), + vec![("completed", 1), ("requested", 1)] + ); + assert_eq!( + facets + .buyer_pubkeys + .iter() + .map(|facet| (facet.key.as_str(), facet.count)) + .collect::<Vec<_>>(), + vec![("buyer-pubkey", 1), ("buyer-pubkey-2", 1)] + ); + } + + #[test] fn workflow_helpers_cover_transition_and_terminal_tables() { assert!(radroots_trade_order_status_can_transition( &TradeOrderStatus::Requested,