commit 3538714739bf448682ecfabf5495818994bb8f9c
parent 581b94fca5cd0e9769b7afd864dbafb15758f9a3
Author: triesap <tyson@radroots.org>
Date: Fri, 26 Jun 2026 05:35:23 +0000
events: enforce public location privacy
Diffstat:
34 files changed, 794 insertions(+), 1116 deletions(-)
diff --git a/crates/events/src/coop.rs b/crates/events/src/coop.rs
@@ -1,6 +1,6 @@
#![forbid(unsafe_code)]
-use crate::farm::RadrootsGcsLocation;
+use crate::gcs::RadrootsGcsLocation;
#[cfg(not(feature = "std"))]
use alloc::{string::String, vec::Vec};
diff --git a/crates/events/src/dto.rs b/crates/events/src/dto.rs
@@ -12,11 +12,9 @@ use crate::{
comment::RadrootsComment,
coop::{RadrootsCoop, RadrootsCoopLocation, RadrootsCoopRef},
document::{RadrootsDocument, RadrootsDocumentSubject},
- farm::{
- RadrootsFarm, RadrootsFarmLocation, RadrootsFarmRef, RadrootsGcsLocation,
- RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon,
- },
+ farm::{RadrootsFarm, RadrootsFarmPublicLocation, RadrootsFarmRef},
follow::{RadrootsFollow, RadrootsFollowProfile},
+ gcs::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon},
geochat::RadrootsGeoChat,
gift_wrap::{RadrootsGiftWrap, RadrootsGiftWrapRecipient},
ids::{
@@ -32,7 +30,7 @@ use crate::{
listing::{
RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
RadrootsListingDeliveryMethod, RadrootsListingImage, RadrootsListingImageSize,
- RadrootsListingLocation, RadrootsListingProduct, RadrootsListingStatus,
+ RadrootsListingProduct, RadrootsListingPublicLocation, RadrootsListingStatus,
},
message::{RadrootsMessage, RadrootsMessageRecipient},
message_file::{RadrootsMessageFile, RadrootsMessageFileDimensions},
@@ -76,7 +74,7 @@ pub fn dto_roots() -> Vec<RootDescriptor> {
RootDescriptor::new::<RadrootsDocument>(),
RootDescriptor::new::<RadrootsDocumentSubject>(),
RootDescriptor::new::<RadrootsFarm>(),
- RootDescriptor::new::<RadrootsFarmLocation>(),
+ RootDescriptor::new::<RadrootsFarmPublicLocation>(),
RootDescriptor::new::<RadrootsFarmRef>(),
RootDescriptor::new::<RadrootsFollow>(),
RootDescriptor::new::<RadrootsFollowProfile>(),
@@ -101,7 +99,7 @@ pub fn dto_roots() -> Vec<RootDescriptor> {
RootDescriptor::new::<RadrootsNostrEvent>(),
RootDescriptor::new::<RadrootsNostrEventRef>(),
RootDescriptor::new::<RadrootsNostrEventPtr>(),
- RootDescriptor::new::<RadrootsListingLocation>(),
+ RootDescriptor::new::<RadrootsListingPublicLocation>(),
RootDescriptor::new::<RadrootsListingProduct>(),
RootDescriptor::new::<RadrootsListingStatus>(),
RootDescriptor::new::<RadrootsListingImage>(),
@@ -758,7 +756,7 @@ impl Dto for RadrootsListing {
.with_field(optional_nullable_field(
"location",
"location",
- <Option<RadrootsListingLocation> as Dto>::describe(ctx),
+ <Option<RadrootsListingPublicLocation> as Dto>::describe(ctx),
"crates/events/src/listing.rs",
78,
))
@@ -1574,7 +1572,11 @@ impl Dto for RadrootsCommercialTransportLaneDto {
"crates/events/src/order.rs",
408,
));
- register(ctx, "RadrootsCommercialTransportLaneDto", TypeDef::Enum(def))
+ register(
+ ctx,
+ "RadrootsCommercialTransportLaneDto",
+ TypeDef::Enum(def),
+ )
}
}
@@ -1695,7 +1697,11 @@ impl Dto for RadrootsListingDiscountRequestDto {
"crates/events/src/order.rs",
408,
));
- register(ctx, "RadrootsListingDiscountRequestDto", TypeDef::Struct(def))
+ register(
+ ctx,
+ "RadrootsListingDiscountRequestDto",
+ TypeDef::Struct(def),
+ )
}
}
@@ -1759,7 +1765,11 @@ impl Dto for RadrootsListingDiscountDecisionDto {
)]),
span("crates/events/src/order.rs", 408),
));
- register(ctx, "RadrootsListingDiscountDecisionDto", TypeDef::Enum(def))
+ register(
+ ctx,
+ "RadrootsListingDiscountDecisionDto",
+ TypeDef::Enum(def),
+ )
}
}
@@ -1783,11 +1793,7 @@ impl Dto for RadrootsListingCancelDto {
impl Dto for RadrootsOrderResponseDto {
fn describe(ctx: &mut DescribeCtx) -> TypeRef {
- accepted_reason_struct(
- ctx,
- "RadrootsOrderResponseDto",
- "RadrootsOrderResponse",
- )
+ accepted_reason_struct(ctx, "RadrootsOrderResponseDto", "RadrootsOrderResponse")
}
}
@@ -1967,7 +1973,11 @@ impl Dto for RadrootsCommercialMessagePayloadDto {
"cancel",
RadrootsListingCancelDto::describe(ctx),
));
- register(ctx, "RadrootsCommercialMessagePayloadDto", TypeDef::Enum(def))
+ register(
+ ctx,
+ "RadrootsCommercialMessagePayloadDto",
+ TypeDef::Enum(def),
+ )
}
}
diff --git a/crates/events/src/farm.rs b/crates/events/src/farm.rs
@@ -11,7 +11,7 @@ pub struct RadrootsFarm {
pub website: Option<String>,
pub picture: Option<String>,
pub banner: Option<String>,
- pub location: Option<RadrootsFarmLocation>,
+ pub location: Option<RadrootsFarmPublicLocation>,
pub tags: Option<Vec<String>>,
}
@@ -26,53 +26,10 @@ pub struct RadrootsFarmRef {
#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug)]
-pub struct RadrootsGeoJsonPoint {
- #[cfg_attr(feature = "serde", serde(rename = "type"))]
- pub r#type: String,
- pub coordinates: [f64; 2],
-}
-
-#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
-#[derive(Clone, Debug)]
-pub struct RadrootsGeoJsonPolygon {
- #[cfg_attr(feature = "serde", serde(rename = "type"))]
- pub r#type: String,
- pub coordinates: Vec<Vec<[f64; 2]>>,
-}
-
-#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
-#[derive(Clone, Debug)]
-pub struct RadrootsGcsLocation {
- pub lat: f64,
- pub lng: f64,
- pub geohash: String,
- pub point: RadrootsGeoJsonPoint,
- pub polygon: RadrootsGeoJsonPolygon,
- pub accuracy: Option<f64>,
- pub altitude: Option<f64>,
- pub tag_0: Option<String>,
- pub label: Option<String>,
- pub area: Option<f64>,
- pub elevation: Option<u32>,
- pub soil: Option<String>,
- pub climate: Option<String>,
- pub gc_id: Option<String>,
- pub gc_name: Option<String>,
- pub gc_admin1_id: Option<String>,
- pub gc_admin1_name: Option<String>,
- pub gc_country_id: Option<String>,
- pub gc_country_name: Option<String>,
-}
-
-#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
-#[derive(Clone, Debug)]
-pub struct RadrootsFarmLocation {
- pub primary: Option<String>,
+pub struct RadrootsFarmPublicLocation {
+ pub primary: String,
pub city: Option<String>,
pub region: Option<String>,
pub country: Option<String>,
- pub gcs: Option<RadrootsGcsLocation>,
+ pub geohash: String,
}
diff --git a/crates/events/src/gcs.rs b/crates/events/src/gcs.rs
@@ -0,0 +1,45 @@
+#[cfg(not(feature = "std"))]
+use alloc::{string::String, vec::Vec};
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug)]
+pub struct RadrootsGeoJsonPoint {
+ #[cfg_attr(feature = "serde", serde(rename = "type"))]
+ pub r#type: String,
+ pub coordinates: [f64; 2],
+}
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug)]
+pub struct RadrootsGeoJsonPolygon {
+ #[cfg_attr(feature = "serde", serde(rename = "type"))]
+ pub r#type: String,
+ pub coordinates: Vec<Vec<[f64; 2]>>,
+}
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug)]
+pub struct RadrootsGcsLocation {
+ pub lat: f64,
+ pub lng: f64,
+ pub geohash: String,
+ pub point: RadrootsGeoJsonPoint,
+ pub polygon: RadrootsGeoJsonPolygon,
+ pub accuracy: Option<f64>,
+ pub altitude: Option<f64>,
+ pub tag_0: Option<String>,
+ pub label: Option<String>,
+ pub area: Option<f64>,
+ pub elevation: Option<u32>,
+ pub soil: Option<String>,
+ pub climate: Option<String>,
+ pub gc_id: Option<String>,
+ pub gc_name: Option<String>,
+ pub gc_admin1_id: Option<String>,
+ pub gc_admin1_name: Option<String>,
+ pub gc_country_id: Option<String>,
+ pub gc_country_name: Option<String>,
+}
diff --git a/crates/events/src/lib.rs b/crates/events/src/lib.rs
@@ -24,6 +24,7 @@ pub mod farm_file;
pub mod farm_workspace;
pub mod file_metadata;
pub mod follow;
+pub mod gcs;
pub mod geochat;
pub mod gift_wrap;
pub mod group;
@@ -37,6 +38,7 @@ pub mod kinds;
pub mod list;
pub mod list_set;
pub mod listing;
+pub mod location;
pub mod message;
pub mod message_file;
pub mod order;
diff --git a/crates/events/src/listing.rs b/crates/events/src/listing.rs
@@ -72,7 +72,7 @@ pub struct RadrootsListing {
pub inventory_available: Option<RadrootsCoreDecimal>,
pub availability: Option<RadrootsListingAvailability>,
pub delivery_method: Option<RadrootsListingDeliveryMethod>,
- pub location: Option<RadrootsListingLocation>,
+ pub location: Option<RadrootsListingPublicLocation>,
pub images: Option<Vec<RadrootsListingImage>>,
}
@@ -112,14 +112,12 @@ pub struct RadrootsListingBin {
#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug)]
-pub struct RadrootsListingLocation {
+pub struct RadrootsListingPublicLocation {
pub primary: String,
pub city: Option<String>,
pub region: Option<String>,
pub country: Option<String>,
- pub lat: Option<f64>,
- pub lng: Option<f64>,
- pub geohash: Option<String>,
+ pub geohash: String,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
diff --git a/crates/events/src/location.rs b/crates/events/src/location.rs
@@ -0,0 +1,30 @@
+pub const RADROOTS_PUBLIC_GEOHASH_PRECISION: usize = 5;
+pub const RADROOTS_PUBLIC_GEOHASH_BASE32: &str = "0123456789bcdefghjkmnpqrstuvwxyz";
+
+pub fn is_public_geohash5(value: &str) -> bool {
+ let value = value.trim();
+ value.len() == RADROOTS_PUBLIC_GEOHASH_PRECISION
+ && value.bytes().all(|byte| {
+ RADROOTS_PUBLIC_GEOHASH_BASE32
+ .as_bytes()
+ .contains(&byte.to_ascii_lowercase())
+ })
+}
+
+pub fn has_textual_locality(
+ primary: &str,
+ city: Option<&str>,
+ region: Option<&str>,
+ country: Option<&str>,
+) -> bool {
+ has_public_location_text(primary)
+ && [city, region, country]
+ .into_iter()
+ .flatten()
+ .any(has_public_location_text)
+}
+
+fn has_public_location_text(value: &str) -> bool {
+ let value = value.trim();
+ !value.is_empty() && !value.eq_ignore_ascii_case("null")
+}
diff --git a/crates/events/src/order.rs b/crates/events/src/order.rs
@@ -283,7 +283,10 @@ pub struct RadrootsOrderInventoryCommitment {
}
#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
-#[cfg_attr(feature = "dto-bindgen", dto(ts(name = "RadrootsOrderDecisionOutcome")))]
+#[cfg_attr(
+ feature = "dto-bindgen",
+ dto(ts(name = "RadrootsOrderDecisionOutcome"))
+)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case", tag = "decision"))]
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -308,10 +311,7 @@ impl RadrootsOrderDecisionOutcome {
}
#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
-#[cfg_attr(
- feature = "dto-bindgen",
- dto(ts(name = "RadrootsOrderDecision"))
-)]
+#[cfg_attr(feature = "dto-bindgen", dto(ts(name = "RadrootsOrderDecision")))]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsOrderDecision {
@@ -365,10 +365,7 @@ pub enum RadrootsCommercialDomain {
}
#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
-#[cfg_attr(
- feature = "dto-bindgen",
- dto(ts(name = "RadrootsOrderEventType"))
-)]
+#[cfg_attr(feature = "dto-bindgen", dto(ts(name = "RadrootsOrderEventType")))]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RadrootsOrderEventType {
diff --git a/crates/events/src/order_economics.rs b/crates/events/src/order_economics.rs
@@ -82,10 +82,7 @@ pub struct RadrootsOrderEconomicItem {
}
#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
-#[cfg_attr(
- feature = "dto-bindgen",
- dto(ts(name = "RadrootsOrderEconomicLine"))
-)]
+#[cfg_attr(feature = "dto-bindgen", dto(ts(name = "RadrootsOrderEconomicLine")))]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsOrderEconomicLine {
@@ -98,10 +95,7 @@ pub struct RadrootsOrderEconomicLine {
}
#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
-#[cfg_attr(
- feature = "dto-bindgen",
- dto(ts(name = "RadrootsOrderEconomicTotals"))
-)]
+#[cfg_attr(feature = "dto-bindgen", dto(ts(name = "RadrootsOrderEconomicTotals")))]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsOrderEconomicTotals {
diff --git a/crates/events/src/plot.rs b/crates/events/src/plot.rs
@@ -1,4 +1,4 @@
-use crate::farm::{RadrootsFarmRef, RadrootsGcsLocation};
+use crate::{farm::RadrootsFarmRef, gcs::RadrootsGcsLocation};
#[cfg(not(feature = "std"))]
use alloc::{string::String, vec::Vec};
diff --git a/crates/events/src/resource_area.rs b/crates/events/src/resource_area.rs
@@ -1,6 +1,6 @@
#![forbid(unsafe_code)]
-use crate::farm::RadrootsGcsLocation;
+use crate::gcs::RadrootsGcsLocation;
#[cfg(not(feature = "std"))]
use alloc::{string::String, vec::Vec};
diff --git a/crates/events/src/trade_validation.rs b/crates/events/src/trade_validation.rs
@@ -32,6 +32,9 @@ pub enum RadrootsTradeValidationListingError {
InvalidInventory,
MissingAvailability,
MissingLocation,
+ MissingLocationLocality,
+ MissingLocationGeohash,
+ InvalidLocationGeohash,
MissingDeliveryMethod,
}
@@ -62,6 +65,9 @@ impl core::fmt::Display for RadrootsTradeValidationListingError {
Self::InvalidInventory => write!(f, "invalid listing inventory"),
Self::MissingAvailability => write!(f, "missing listing availability"),
Self::MissingLocation => write!(f, "missing listing location"),
+ Self::MissingLocationLocality => write!(f, "missing listing location locality"),
+ Self::MissingLocationGeohash => write!(f, "missing listing location geohash"),
+ Self::InvalidLocationGeohash => write!(f, "invalid listing location geohash"),
Self::MissingDeliveryMethod => write!(f, "missing listing delivery method"),
}
}
diff --git a/crates/events_codec/src/coop/mod.rs b/crates/events_codec/src/coop/mod.rs
@@ -15,9 +15,8 @@ mod tests {
};
use crate::error::EventEncodeError;
use radroots_events::coop::{RadrootsCoop, RadrootsCoopLocation, RadrootsCoopRef};
- use radroots_events::farm::{
- RadrootsFarmRef, RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon,
- };
+ use radroots_events::farm::RadrootsFarmRef;
+ use radroots_events::gcs::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon};
#[cfg(feature = "serde_json")]
use radroots_events::kinds::KIND_COOP;
diff --git a/crates/events_codec/src/farm/decode.rs b/crates/events_codec/src/farm/decode.rs
@@ -6,13 +6,20 @@ use alloc::{
vec::Vec,
};
-use radroots_events::{RadrootsNostrEvent, farm::RadrootsFarm, kinds::KIND_FARM, tags::TAG_D};
+use radroots_events::{
+ RadrootsNostrEvent,
+ farm::RadrootsFarm,
+ kinds::KIND_FARM,
+ location::{has_textual_locality, is_public_geohash5},
+ tags::TAG_D,
+};
use crate::d_tag::validate_d_tag_tag;
use crate::error::EventParseError;
use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent};
const DEFAULT_KIND: u32 = KIND_FARM;
+const TAG_G: &str = "g";
fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, EventParseError> {
let tag = tags
@@ -45,6 +52,7 @@ pub fn farm_from_event(
return Err(EventParseError::InvalidJson("content"));
}
let d_tag = parse_d_tag(tags)?;
+ reject_private_farm_location_tags(tags)?;
reject_private_farm_ops_content(content)?;
let mut farm: RadrootsFarm =
serde_json::from_str(content).map_err(|_| EventParseError::InvalidJson("content"))?;
@@ -54,10 +62,47 @@ pub fn farm_from_event(
} else if farm.d_tag != d_tag {
return Err(EventParseError::InvalidTag(TAG_D));
}
+ if let Some(location) = farm.location.as_ref() {
+ if !is_public_geohash5(&location.geohash)
+ || !has_textual_locality(
+ &location.primary,
+ location.city.as_deref(),
+ location.region.as_deref(),
+ location.country.as_deref(),
+ )
+ {
+ return Err(EventParseError::InvalidTag(TAG_G));
+ }
+ }
Ok(farm)
}
+fn reject_private_farm_location_tags(tags: &[Vec<String>]) -> Result<(), EventParseError> {
+ for tag in tags {
+ let Some(key) = tag.first().map(|value| value.as_str()) else {
+ continue;
+ };
+ match key {
+ TAG_G => {
+ let Some(value) = tag.get(1).map(|value| value.trim()) else {
+ return Err(EventParseError::InvalidTag(TAG_G));
+ };
+ if !is_public_geohash5(value) {
+ return Err(EventParseError::InvalidTag(TAG_G));
+ }
+ }
+ "dd" => return Err(EventParseError::InvalidTag("dd")),
+ "dd.lat" => return Err(EventParseError::InvalidTag("dd.lat")),
+ "dd.lon" => return Err(EventParseError::InvalidTag("dd.lon")),
+ "l" => return Err(EventParseError::InvalidTag("l")),
+ "L" => return Err(EventParseError::InvalidTag("L")),
+ _ => {}
+ }
+ }
+ Ok(())
+}
+
fn reject_private_farm_ops_content(content: &str) -> Result<(), EventParseError> {
let value: serde_json::Value =
serde_json::from_str(content).map_err(|_| EventParseError::InvalidJson("content"))?;
@@ -83,6 +128,25 @@ fn reject_private_farm_ops_content(content: &str) -> Result<(), EventParseError>
return Err(EventParseError::InvalidJson("content"));
}
}
+ if let Some(location) = object.get("location").and_then(|value| value.as_object()) {
+ for key in [
+ "gcs",
+ "lat",
+ "lng",
+ "lon",
+ "point",
+ "polygon",
+ "coordinates",
+ "accuracy",
+ "altitude",
+ "label",
+ "tag_0",
+ ] {
+ if location.contains_key(key) {
+ return Err(EventParseError::InvalidJson("content"));
+ }
+ }
+ }
Ok(())
}
diff --git a/crates/events_codec/src/farm/encode.rs b/crates/events_codec/src/farm/encode.rs
@@ -7,6 +7,7 @@ use alloc::{
use radroots_events::{
farm::{RadrootsFarm, RadrootsFarmRef},
kinds::KIND_FARM,
+ location::{has_textual_locality, is_public_geohash5},
tags::TAG_D,
};
@@ -38,14 +39,21 @@ pub fn farm_build_tags(farm: &RadrootsFarm) -> Result<Vec<Vec<String>>, EventEnc
push_tag(&mut tags, TAG_T, item);
}
}
- if let Some(geohash) = farm
- .location
- .as_ref()
- .and_then(|location| location.gcs.as_ref())
- .map(|gcs| gcs.geohash.trim())
- {
+ if let Some(location) = farm.location.as_ref() {
+ if !has_textual_locality(
+ &location.primary,
+ location.city.as_deref(),
+ location.region.as_deref(),
+ location.country.as_deref(),
+ ) {
+ return Err(EventEncodeError::EmptyRequiredField("location.locality"));
+ }
+ let geohash = location.geohash.trim();
if geohash.is_empty() {
- return Err(EventEncodeError::EmptyRequiredField("location.gcs.geohash"));
+ return Err(EventEncodeError::EmptyRequiredField("location.geohash"));
+ }
+ if !is_public_geohash5(geohash) {
+ return Err(EventEncodeError::InvalidField("location.geohash"));
}
push_tag(&mut tags, TAG_G, geohash);
}
diff --git a/crates/events_codec/src/farm/mod.rs b/crates/events_codec/src/farm/mod.rs
@@ -16,10 +16,7 @@ mod tests {
RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
RadrootsCoreQuantityPrice, RadrootsCoreUnit,
};
- use radroots_events::farm::{
- RadrootsFarm, RadrootsFarmLocation, RadrootsFarmRef, RadrootsGcsLocation,
- RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon,
- };
+ use radroots_events::farm::{RadrootsFarm, RadrootsFarmPublicLocation, RadrootsFarmRef};
use radroots_events::ids::{RadrootsDTag, RadrootsInventoryBinId};
#[cfg(feature = "serde_json")]
use radroots_events::kinds::KIND_FARM;
@@ -43,43 +40,12 @@ mod tests {
website: None,
picture: None,
banner: None,
- location: Some(RadrootsFarmLocation {
- primary: None,
- city: None,
- region: None,
- country: None,
- gcs: Some(RadrootsGcsLocation {
- lat: 37.0,
- lng: -122.0,
- geohash: "9q8yy".to_string(),
- point: RadrootsGeoJsonPoint {
- r#type: "Point".to_string(),
- coordinates: [-122.0, 37.0],
- },
- polygon: RadrootsGeoJsonPolygon {
- r#type: "Polygon".to_string(),
- coordinates: vec![vec![
- [-122.0, 37.0],
- [-122.0, 37.0001],
- [-122.0001, 37.0001],
- [-122.0, 37.0],
- ]],
- },
- accuracy: None,
- altitude: None,
- tag_0: None,
- label: None,
- area: None,
- elevation: None,
- soil: None,
- climate: None,
- gc_id: None,
- gc_name: None,
- gc_admin1_id: None,
- gc_admin1_name: None,
- gc_country_id: None,
- gc_country_name: None,
- }),
+ location: Some(RadrootsFarmPublicLocation {
+ primary: "Test Farm".to_string(),
+ city: Some("Santa Cruz".to_string()),
+ region: Some("California".to_string()),
+ country: Some("US".to_string()),
+ geohash: "9q8yy".to_string(),
}),
tags: Some(vec!["orchard".to_string()]),
};
@@ -209,43 +175,12 @@ mod tests {
website: None,
picture: None,
banner: None,
- location: Some(RadrootsFarmLocation {
- primary: None,
- city: None,
- region: None,
- country: None,
- gcs: Some(RadrootsGcsLocation {
- lat: 37.0,
- lng: -122.0,
- geohash: "9q8yy".to_string(),
- point: RadrootsGeoJsonPoint {
- r#type: "Point".to_string(),
- coordinates: [-122.0, 37.0],
- },
- polygon: RadrootsGeoJsonPolygon {
- r#type: "Polygon".to_string(),
- coordinates: vec![vec![
- [-122.0, 37.0],
- [-122.0, 37.0001],
- [-122.0001, 37.0001],
- [-122.0, 37.0],
- ]],
- },
- accuracy: None,
- altitude: None,
- tag_0: None,
- label: None,
- area: None,
- elevation: None,
- soil: None,
- climate: None,
- gc_id: None,
- gc_name: None,
- gc_admin1_id: None,
- gc_admin1_name: None,
- gc_country_id: None,
- gc_country_name: None,
- }),
+ location: Some(RadrootsFarmPublicLocation {
+ primary: "Test Farm".to_string(),
+ city: Some("Santa Cruz".to_string()),
+ region: Some("California".to_string()),
+ country: Some("US".to_string()),
+ geohash: "9q8yy".to_string(),
}),
tags: None,
};
@@ -260,26 +195,19 @@ mod tests {
assert!(matches!(err, EventEncodeError::EmptyRequiredField("name")));
farm.name = "Test Farm".to_string();
- farm.location
- .as_mut()
- .expect("location")
- .gcs
- .as_mut()
- .expect("gcs")
- .geohash = " ".to_string();
+ farm.location.as_mut().expect("location").geohash = " ".to_string();
let err = farm_build_tags(&farm).expect_err("expected empty geohash");
assert!(matches!(
err,
- EventEncodeError::EmptyRequiredField("location.gcs.geohash")
+ EventEncodeError::EmptyRequiredField("location.geohash")
));
- farm.location.as_mut().expect("location").gcs = None;
- let tags = farm_build_tags(&farm).expect("string-only farm location should be allowed");
- assert!(
- !tags
- .iter()
- .any(|tag| tag.get(0).map(|v| v.as_str()) == Some("g"))
- );
+ farm.location.as_mut().expect("location").geohash = "9q8yy6".to_string();
+ let err = farm_build_tags(&farm).expect_err("expected invalid geohash");
+ assert!(matches!(
+ err,
+ EventEncodeError::InvalidField("location.geohash")
+ ));
let err = farm_ref_tags(&RadrootsFarmRef {
pubkey: " ".to_string(),
diff --git a/crates/events_codec/src/listing/decode.rs b/crates/events_codec/src/listing/decode.rs
@@ -18,8 +18,9 @@ use radroots_events::{
listing::{
RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
RadrootsListingDeliveryMethod, RadrootsListingImage, RadrootsListingImageSize,
- RadrootsListingLocation, RadrootsListingProduct, RadrootsListingStatus,
+ RadrootsListingProduct, RadrootsListingPublicLocation, RadrootsListingStatus,
},
+ location::is_public_geohash5,
plot::RadrootsPlotRef,
resource_area::RadrootsResourceAreaRef,
tags::{TAG_D, TAG_PUBLISHED_AT},
@@ -42,18 +43,61 @@ const TAG_RADROOTS_PLOT: &str = "radroots:plot";
const TAG_LOCATION: &str = "location";
const TAG_IMAGE: &str = "image";
const TAG_GEOHASH: &str = "g";
+const TAG_LABEL: &str = "l";
+const TAG_LABEL_NS: &str = "L";
+const TAG_DD: &str = "dd";
+const TAG_DD_LAT: &str = "dd.lat";
+const TAG_DD_LON: &str = "dd.lon";
const TAG_INVENTORY: &str = "inventory";
const TAG_DELIVERY: &str = "delivery";
const TAG_RADROOTS_AVAILABILITY_START: &str = "radroots:availability_start";
const TAG_STATUS: &str = "status";
const TAG_EXPIRES_AT: &str = "expires_at";
+struct ListingLocationDraft {
+ primary: String,
+ city: Option<String>,
+ region: Option<String>,
+ country: Option<String>,
+}
+
fn parse_decimal(value: &str, field: &'static str) -> Result<RadrootsCoreDecimal, EventParseError> {
value
.parse::<RadrootsCoreDecimal>()
.map_err(|_| EventParseError::InvalidTag(field))
}
+fn reject_private_listing_location_content(content: &str) -> Result<(), EventParseError> {
+ let trimmed = content.trim();
+ if !trimmed.starts_with('{') {
+ return Ok(());
+ }
+ let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
+ return Ok(());
+ };
+ let Some(location) = value.get("location").and_then(|value| value.as_object()) else {
+ return Ok(());
+ };
+ for key in [
+ "lat",
+ "lng",
+ "lon",
+ "point",
+ "polygon",
+ "coordinates",
+ "accuracy",
+ "altitude",
+ "label",
+ "tag_0",
+ "gcs",
+ ] {
+ if location.contains_key(key) {
+ return Err(EventParseError::InvalidJson("content"));
+ }
+ }
+ Ok(())
+}
+
fn parse_currency(
value: &str,
field: &'static str,
@@ -229,8 +273,9 @@ pub fn listing_from_event(
pub fn listing_from_event_parts(
tags: &[Vec<String>],
- _content: &str,
+ content: &str,
) -> Result<RadrootsListing, EventParseError> {
+ reject_private_listing_location_content(content)?;
let d_tag = parse_d_tag(tags)?;
let farm_ref = parse_farm_ref(tags)?;
let farm_pubkey = parse_farm_pubkey(tags)?;
@@ -252,7 +297,7 @@ pub fn listing_from_event_parts(
let mut bin_drafts: Vec<BinDraft> = Vec::new();
let mut bin_order = 0usize;
let mut discounts: Vec<RadrootsCoreDiscount> = Vec::new();
- let mut location: Option<RadrootsListingLocation> = None;
+ let mut location: Option<ListingLocationDraft> = None;
let mut inventory_available: Option<RadrootsCoreDecimal> = None;
let mut availability_status: Option<RadrootsListingStatus> = None;
let mut availability_start: Option<u64> = None;
@@ -262,10 +307,6 @@ pub fn listing_from_event_parts(
let mut geohash: Option<String> = None;
let mut published_at: Option<u64> = None;
- let has_structured_location = tags
- .iter()
- .any(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_LOCATION) && tag.len() >= 3);
-
for tag in tags {
if tag.is_empty() {
continue;
@@ -283,7 +324,7 @@ pub fn listing_from_event_parts(
"location" => {
let parse_structured_location = match tag.len() {
0 | 1 => false,
- 2 => !has_structured_location && location.is_none(),
+ 2 => false,
_ => true,
};
if parse_structured_location {
@@ -291,14 +332,11 @@ pub fn listing_from_event_parts(
.get(1)
.and_then(|value| clean_value(value))
.ok_or(EventParseError::InvalidTag(TAG_LOCATION))?;
- let mut parsed = RadrootsListingLocation {
+ let mut parsed = ListingLocationDraft {
primary,
city: None,
region: None,
country: None,
- lat: None,
- lng: None,
- geohash: None,
};
if let Some(city) = tag.get(2).and_then(|value| clean_value(value)) {
parsed.city = Some(city);
@@ -409,10 +447,24 @@ pub fn listing_from_event_parts(
discounts.push(parse_discount(payload)?);
}
TAG_GEOHASH => {
- if let Some(value) = tag.get(1).and_then(|value| clean_value(value)) {
- geohash = Some(value);
+ let value = tag
+ .get(1)
+ .and_then(|value| clean_value(value))
+ .ok_or(EventParseError::InvalidTag(TAG_GEOHASH))?;
+ if !is_public_geohash5(&value) {
+ return Err(EventParseError::InvalidTag(TAG_GEOHASH));
}
+ let value = value.to_ascii_lowercase();
+ if geohash.as_ref().is_some_and(|existing| existing != &value) {
+ return Err(EventParseError::InvalidTag(TAG_GEOHASH));
+ }
+ geohash = Some(value);
}
+ TAG_DD => return Err(EventParseError::InvalidTag(TAG_DD)),
+ TAG_DD_LAT => return Err(EventParseError::InvalidTag(TAG_DD_LAT)),
+ TAG_DD_LON => return Err(EventParseError::InvalidTag(TAG_DD_LON)),
+ TAG_LABEL => return Err(EventParseError::InvalidTag(TAG_LABEL)),
+ TAG_LABEL_NS => return Err(EventParseError::InvalidTag(TAG_LABEL_NS)),
TAG_INVENTORY => {
let value = tag
.get(1)
@@ -477,10 +529,18 @@ pub fn listing_from_event_parts(
},
};
- let location = location.map(|mut location| {
- location.geohash = location.geohash.or(geohash);
- location
- });
+ let location = location
+ .map(|location| {
+ let geohash = geohash.ok_or(EventParseError::InvalidTag(TAG_GEOHASH))?;
+ Ok(RadrootsListingPublicLocation {
+ primary: location.primary,
+ city: location.city,
+ region: location.region,
+ country: location.country,
+ geohash,
+ })
+ })
+ .transpose()?;
if farm_pubkey != farm_ref.pubkey {
return Err(EventParseError::InvalidTag(TAG_P));
diff --git a/crates/events_codec/src/listing/tags.rs b/crates/events_codec/src/listing/tags.rs
@@ -8,8 +8,6 @@ use alloc::{
vec::Vec,
};
-use core::cmp;
-
#[cfg(any(feature = "serde_json", test))]
use radroots_core::RadrootsCoreDiscount;
use radroots_core::RadrootsCoreMoney;
@@ -17,9 +15,10 @@ use radroots_events::farm::RadrootsFarmRef;
use radroots_events::kinds::{KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA};
use radroots_events::listing::{
RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
- RadrootsListingDeliveryMethod, RadrootsListingImage, RadrootsListingLocation,
+ RadrootsListingDeliveryMethod, RadrootsListingImage, RadrootsListingPublicLocation,
RadrootsListingStatus,
};
+use radroots_events::location::{has_textual_locality, is_public_geohash5};
use radroots_events::plot::RadrootsPlotRef;
use radroots_events::resource_area::RadrootsResourceAreaRef;
use radroots_events::tags::{TAG_D, TAG_PUBLISHED_AT};
@@ -38,11 +37,6 @@ const TAG_RADROOTS_PLOT: &str = "radroots:plot";
const TAG_LOCATION: &str = "location";
const TAG_IMAGE: &str = "image";
const TAG_GEOHASH: &str = "g";
-const TAG_LABEL: &str = "l";
-const TAG_LABEL_NS: &str = "L";
-const TAG_DD: &str = "dd";
-const TAG_DD_LAT: &str = "dd.lat";
-const TAG_DD_LON: &str = "dd.lon";
const TAG_INVENTORY: &str = "inventory";
const TAG_DELIVERY: &str = "delivery";
const TAG_RADROOTS_AVAILABILITY_START: &str = "radroots:availability_start";
@@ -51,17 +45,9 @@ const TAG_EXPIRES_AT: &str = "expires_at";
const TAG_P: &str = "p";
const TAG_A: &str = "a";
-const GEOHASH_PRECISION_DEFAULT: usize = 9;
-const DD_MAX_RESOLUTION_DEFAULT: u32 = 9;
-
-const BASE32_CODES: &[u8; 32] = b"0123456789bcdefghjkmnpqrstuvwxyz";
-
#[derive(Clone, Copy, Debug)]
pub struct ListingTagOptions {
- pub geohash_precision: usize,
- pub dd_max_resolution: u32,
pub include_geohash: bool,
- pub include_gps: bool,
pub include_inventory: bool,
pub include_availability: bool,
pub include_delivery: bool,
@@ -70,10 +56,7 @@ pub struct ListingTagOptions {
impl Default for ListingTagOptions {
fn default() -> Self {
Self {
- geohash_precision: GEOHASH_PRECISION_DEFAULT,
- dd_max_resolution: DD_MAX_RESOLUTION_DEFAULT,
include_geohash: true,
- include_gps: true,
include_inventory: false,
include_availability: false,
include_delivery: false,
@@ -230,9 +213,17 @@ pub fn listing_tags_with_options(
tags.push(tag);
}
- if let Some(location) = &listing.location
- && let Some(primary) = clean_value(&location.primary)
- {
+ if let Some(location) = &listing.location {
+ let primary = clean_value(&location.primary)
+ .ok_or(EventEncodeError::EmptyRequiredField("location.primary"))?;
+ if !has_textual_locality(
+ &location.primary,
+ location.city.as_deref(),
+ location.region.as_deref(),
+ location.country.as_deref(),
+ ) {
+ return Err(EventEncodeError::EmptyRequiredField("location.locality"));
+ }
let mut tag = Vec::with_capacity(5);
tag.push(TAG_LOCATION.to_string());
tag.push(primary);
@@ -246,8 +237,8 @@ pub fn listing_tags_with_options(
tag.push(country);
}
tags.push(tag);
- if options.include_geohash || options.include_gps {
- push_location_geotags(&mut tags, location, options);
+ if options.include_geohash {
+ push_location_geotag(&mut tags, location)?;
}
}
@@ -408,169 +399,17 @@ fn tag_listing_image(image: &RadrootsListingImage) -> Option<Vec<String>> {
Some(tag)
}
-fn push_location_geotags(
+fn push_location_geotag(
tags: &mut Vec<Vec<String>>,
- location: &RadrootsListingLocation,
- options: ListingTagOptions,
-) {
- let mut lat = location.lat.filter(|value| value.is_finite());
- let mut lon = location.lng.filter(|value| value.is_finite());
- let location_geohash = location.geohash.as_deref().and_then(clean_value);
-
- let geohash = if options.include_geohash {
- if let (Some(lat), Some(lon)) = (lat, lon) {
- let precision = options.geohash_precision.max(1);
- Some(geohash_encode(lat, lon, precision))
- } else {
- location_geohash.clone()
- }
- } else {
- None
- };
-
- if let Some(geohash) = geohash.as_ref() {
- for idx in (1..=geohash.len()).rev() {
- tags.push(vec![TAG_GEOHASH.to_string(), geohash[..idx].to_string()]);
- }
- }
-
- if options.include_gps {
- if (lat.is_none() || lon.is_none())
- && let Some(geohash) = geohash.as_deref().or(location_geohash.as_deref())
- && let Some((decoded_lat, decoded_lon)) = geohash_decode(geohash)
- {
- lat = Some(decoded_lat);
- lon = Some(decoded_lon);
- }
- if let (Some(lat), Some(lon)) = (lat, lon) {
- tags.push(vec![
- TAG_LABEL.to_string(),
- format!("{lat}, {lon}"),
- TAG_DD.to_string(),
- ]);
- let max_resolution = options.dd_max_resolution.max(1);
- let lat_resolution = calculate_resolution(lat, max_resolution);
- let lon_resolution = calculate_resolution(lon, max_resolution);
- tags.push(vec![TAG_LABEL_NS.to_string(), TAG_DD_LAT.to_string()]);
- for idx in (1..=lat_resolution).rev() {
- let truncated = truncate_to_resolution(lat, idx);
- tags.push(vec![
- TAG_LABEL.to_string(),
- truncated.to_string(),
- TAG_DD_LAT.to_string(),
- ]);
- }
- tags.push(vec![TAG_LABEL_NS.to_string(), TAG_DD_LON.to_string()]);
- for idx in (1..=lon_resolution).rev() {
- let truncated = truncate_to_resolution(lon, idx);
- tags.push(vec![
- TAG_LABEL.to_string(),
- truncated.to_string(),
- TAG_DD_LON.to_string(),
- ]);
- }
- }
- }
-}
-
-fn calculate_resolution(value: f64, max: u32) -> u32 {
- let s = value.to_string();
- let decimals = s.split('.').nth(1).map(|v| v.len() as u32).unwrap_or(0);
- cmp::min(decimals, max.max(1)).max(1)
-}
-
-fn truncate_to_resolution(value: f64, resolution: u32) -> f64 {
- let multiplier = 10_f64.powi(resolution as i32);
- (value * multiplier).floor() / multiplier
-}
-
-fn geohash_encode(latitude: f64, longitude: f64, precision: usize) -> String {
- let precision = precision.max(1);
- let mut out = String::with_capacity(precision);
- let mut bits: u8 = 0;
- let mut bits_total: u8 = 0;
- let mut hash_value: u8 = 0;
- let mut max_lat = 90.0;
- let mut min_lat = -90.0;
- let mut max_lon = 180.0;
- let mut min_lon = -180.0;
-
- while out.len() < precision {
- if bits_total.is_multiple_of(2) {
- let mid = (max_lon + min_lon) / 2.0;
- if longitude > mid {
- hash_value = (hash_value << 1) + 1;
- min_lon = mid;
- } else {
- hash_value <<= 1;
- max_lon = mid;
- }
- } else {
- let mid = (max_lat + min_lat) / 2.0;
- if latitude > mid {
- hash_value = (hash_value << 1) + 1;
- min_lat = mid;
- } else {
- hash_value <<= 1;
- max_lat = mid;
- }
- }
- bits += 1;
- bits_total += 1;
- if bits == 5 {
- out.push(BASE32_CODES[hash_value as usize] as char);
- bits = 0;
- hash_value = 0;
- }
- }
- out
-}
-
-fn geohash_decode(hash: &str) -> Option<(f64, f64)> {
- let (min_lat, min_lon, max_lat, max_lon) = geohash_decode_bbox(hash)?;
- let lat = (min_lat + max_lat) / 2.0;
- let lon = (min_lon + max_lon) / 2.0;
- Some((lat, lon))
-}
-
-fn geohash_decode_bbox(hash: &str) -> Option<(f64, f64, f64, f64)> {
- let mut is_lon = true;
- let mut max_lat = 90.0;
- let mut min_lat = -90.0;
- let mut max_lon = 180.0;
- let mut min_lon = -180.0;
-
- for b in hash.bytes() {
- let value = base32_value(b)?;
- for bits in (0..5).rev() {
- let bit = (value >> bits) & 1;
- if is_lon {
- let mid = (max_lon + min_lon) / 2.0;
- if bit == 1 {
- min_lon = mid;
- } else {
- max_lon = mid;
- }
- } else {
- let mid = (max_lat + min_lat) / 2.0;
- if bit == 1 {
- min_lat = mid;
- } else {
- max_lat = mid;
- }
- }
- is_lon = !is_lon;
- }
+ location: &RadrootsListingPublicLocation,
+) -> Result<(), EventEncodeError> {
+ let geohash = clean_value(&location.geohash)
+ .ok_or(EventEncodeError::EmptyRequiredField("location.geohash"))?;
+ if !is_public_geohash5(&geohash) {
+ return Err(EventEncodeError::InvalidField("location.geohash"));
}
- Some((min_lat, min_lon, max_lat, max_lon))
-}
-
-fn base32_value(c: u8) -> Option<u8> {
- let needle = c.to_ascii_lowercase();
- BASE32_CODES
- .iter()
- .position(|&b| b == needle)
- .map(|idx| idx as u8)
+ tags.push(vec![TAG_GEOHASH.to_string(), geohash.to_ascii_lowercase()]);
+ Ok(())
}
fn push_tag_value(tags: &mut Vec<Vec<String>>, key: &str, value: &str) {
@@ -702,14 +541,12 @@ mod tests {
end: Some(20),
}),
delivery_method: Some(RadrootsListingDeliveryMethod::Pickup),
- location: Some(RadrootsListingLocation {
+ location: Some(RadrootsListingPublicLocation {
primary: "Moyobamba".to_string(),
city: Some("Moyobamba".to_string()),
region: Some("San Martin".to_string()),
country: Some("PE".to_string()),
- lat: Some(-6.0346),
- lng: Some(-76.9714),
- geohash: None,
+ geohash: "9q8yy".to_string(),
}),
images: Some(vec![
RadrootsListingImage {
@@ -733,14 +570,12 @@ mod tests {
fn options_defaults_and_trade_fields() {
let defaults = ListingTagOptions::default();
assert!(defaults.include_geohash);
- assert!(defaults.include_gps);
assert!(!defaults.include_inventory);
assert!(!defaults.include_availability);
assert!(!defaults.include_delivery);
- assert_eq!(defaults.geohash_precision, GEOHASH_PRECISION_DEFAULT);
- assert_eq!(defaults.dd_max_resolution, DD_MAX_RESOLUTION_DEFAULT);
let trade = ListingTagOptions::with_trade_fields();
+ assert!(trade.include_geohash);
assert!(trade.include_inventory);
assert!(trade.include_availability);
assert!(trade.include_delivery);
@@ -761,187 +596,56 @@ mod tests {
}
#[test]
- fn base32_and_geohash_helpers_cover_branches() {
- assert_eq!(base32_value(b'0'), Some(0));
- assert_eq!(base32_value(b'B'), Some(10));
- assert_eq!(base32_value(b'?'), None);
-
- assert_eq!(geohash_encode(1.0, 1.0, 0).len(), 1);
- let geohash = geohash_encode(-6.0346, -76.9714, 9);
- assert_eq!(geohash.len(), 9);
- let decoded = geohash_decode(&geohash).expect("decode geohash");
- assert!(decoded.0.is_finite());
- assert!(decoded.1.is_finite());
- assert!(geohash_decode_bbox(&geohash).is_some());
- assert!(geohash_decode("invalid*").is_none());
- }
-
- #[test]
- fn calculate_and_truncate_resolution_cover_integer_and_fractional() {
- assert_eq!(calculate_resolution(10.0, 9), 1);
- assert_eq!(calculate_resolution(1.23456, 3), 3);
- assert_eq!(calculate_resolution(1.2, 0), 1);
- assert_eq!(truncate_to_resolution(12.9876, 2), 12.98);
- }
-
- #[test]
- fn location_geotags_cover_lat_lon_and_geohash_decode_paths() {
+ fn location_geotag_accepts_public_geohash5() {
let mut tags = Vec::new();
- let location = RadrootsListingLocation {
+ let location = RadrootsListingPublicLocation {
primary: "Test".to_string(),
- city: None,
+ city: Some("Town".to_string()),
region: None,
country: None,
- lat: Some(-6.0346),
- lng: Some(-76.9714),
- geohash: None,
+ geohash: "9Q8YY".to_string(),
};
- push_location_geotags(&mut tags, &location, ListingTagOptions::default());
- assert!(
- tags.iter()
- .any(|tag| tag.first().map(|v| v.as_str()) == Some("g"))
- );
- assert!(tags.iter().any(|tag| {
- tag.first().map(|v| v.as_str()) == Some("L")
- && tag.get(1).map(|v| v.as_str()) == Some("dd.lat")
- }));
- assert!(tags.iter().any(|tag| {
- tag.first().map(|v| v.as_str()) == Some("L")
- && tag.get(1).map(|v| v.as_str()) == Some("dd.lon")
- }));
+ push_location_geotag(&mut tags, &location).expect("public geohash");
- let mut decoded_tags = Vec::new();
- let location_with_geohash = RadrootsListingLocation {
- primary: "Test".to_string(),
- city: None,
- region: None,
- country: None,
- lat: None,
- lng: None,
- geohash: Some("6gkzwgjzn".to_string()),
- };
- push_location_geotags(
- &mut decoded_tags,
- &location_with_geohash,
- ListingTagOptions {
- include_geohash: false,
- include_gps: true,
- ..ListingTagOptions::default()
- },
- );
- let decoded_l_scopes: Vec<&str> = decoded_tags
- .iter()
- .filter(|tag| tag.first().map(|v| v.as_str()) == Some("l"))
- .filter_map(|tag| tag.get(2).map(|v| v.as_str()))
- .collect();
- assert!(decoded_l_scopes.contains(&"dd"));
- assert!(decoded_l_scopes.contains(&"dd.lat"));
-
- let mut invalid_tags = Vec::new();
- let invalid_geohash = RadrootsListingLocation {
- primary: "Test".to_string(),
- city: None,
- region: None,
- country: None,
- lat: None,
- lng: None,
- geohash: Some("???".to_string()),
- };
- push_location_geotags(
- &mut invalid_tags,
- &invalid_geohash,
- ListingTagOptions {
- include_geohash: false,
- include_gps: true,
- ..ListingTagOptions::default()
- },
- );
- assert!(find_tag(&invalid_tags, "l").is_none());
+ assert_eq!(tags, vec![vec!["g".to_string(), "9q8yy".to_string()]]);
+ assert!(find_tag(&tags, "l").is_none());
+ assert!(find_tag(&tags, "L").is_none());
+ }
- let mut geohash_only_tags = Vec::new();
- let geohash_only_location = RadrootsListingLocation {
+ #[test]
+ fn location_geotag_rejects_blank_geohash() {
+ let mut tags = Vec::new();
+ let location = RadrootsListingPublicLocation {
primary: "Test".to_string(),
- city: None,
+ city: Some("Town".to_string()),
region: None,
country: None,
- lat: None,
- lng: None,
- geohash: Some("6gkzwgjzn".to_string()),
+ geohash: " ".to_string(),
};
- push_location_geotags(
- &mut geohash_only_tags,
- &geohash_only_location,
- ListingTagOptions {
- include_geohash: true,
- include_gps: false,
- ..ListingTagOptions::default()
- },
- );
- assert!(find_tag(&geohash_only_tags, "g").is_some());
- let mut invalid_with_geohash_enabled = Vec::new();
- push_location_geotags(
- &mut invalid_with_geohash_enabled,
- &invalid_geohash,
- ListingTagOptions {
- include_geohash: true,
- include_gps: true,
- ..ListingTagOptions::default()
- },
- );
- assert!(find_tag(&invalid_with_geohash_enabled, "g").is_some());
- assert!(
- !invalid_with_geohash_enabled
- .iter()
- .any(|tag| tag.first().map(|v| v.as_str()) == Some("l"))
- );
+ assert!(matches!(
+ push_location_geotag(&mut tags, &location),
+ Err(EventEncodeError::EmptyRequiredField("location.geohash"))
+ ));
+ assert!(tags.is_empty());
+ }
- let mut no_coordinate_tags = Vec::new();
- let no_coordinate_location = RadrootsListingLocation {
+ #[test]
+ fn location_geotag_rejects_non_public_geohash() {
+ let mut tags = Vec::new();
+ let location = RadrootsListingPublicLocation {
primary: "Test".to_string(),
- city: None,
+ city: Some("Town".to_string()),
region: None,
country: None,
- lat: None,
- lng: None,
- geohash: None,
+ geohash: "9q8yyz".to_string(),
};
- push_location_geotags(
- &mut no_coordinate_tags,
- &no_coordinate_location,
- ListingTagOptions {
- include_geohash: false,
- include_gps: true,
- ..ListingTagOptions::default()
- },
- );
- assert!(find_tag(&no_coordinate_tags, "l").is_none());
- let mut partial_coordinate_tags = Vec::new();
- let partial_coordinate_location = RadrootsListingLocation {
- primary: "Test".to_string(),
- city: None,
- region: None,
- country: None,
- lat: Some(-6.03),
- lng: None,
- geohash: Some("6gkzwgjzn".to_string()),
- };
- push_location_geotags(
- &mut partial_coordinate_tags,
- &partial_coordinate_location,
- ListingTagOptions {
- include_geohash: false,
- include_gps: true,
- ..ListingTagOptions::default()
- },
- );
- let partial_l_scopes: Vec<&str> = partial_coordinate_tags
- .iter()
- .filter(|tag| tag.first().map(|v| v.as_str()) == Some("l"))
- .filter_map(|tag| tag.get(2).map(|v| v.as_str()))
- .collect();
- assert!(partial_l_scopes.contains(&"dd"));
+ assert!(matches!(
+ push_location_geotag(&mut tags, &location),
+ Err(EventEncodeError::InvalidField("location.geohash"))
+ ));
+ assert!(tags.is_empty());
}
#[test]
@@ -1365,7 +1069,6 @@ mod tests {
&no_geo,
ListingTagOptions {
include_geohash: false,
- include_gps: false,
..ListingTagOptions::default()
},
)
@@ -1378,24 +1081,12 @@ mod tests {
&no_geo,
ListingTagOptions {
include_geohash: true,
- include_gps: false,
..ListingTagOptions::default()
},
)
.expect("location with geohash only");
assert!(find_tag(&geohash_only_tags, "g").is_some());
assert!(find_tag(&geohash_only_tags, "l").is_none());
-
- let gps_only_tags = listing_tags_with_options(
- &no_geo,
- ListingTagOptions {
- include_geohash: false,
- include_gps: true,
- ..ListingTagOptions::default()
- },
- )
- .expect("location with gps only");
- assert!(find_tag(&gps_only_tags, "l").is_some());
}
#[test]
@@ -1508,15 +1199,7 @@ mod tests {
listing.discounts = None;
listing.product.location = Some(" null ".to_string());
listing.product.profile = Some(" ".to_string());
- listing.location = Some(RadrootsListingLocation {
- primary: "null".to_string(),
- city: Some("city".to_string()),
- region: Some("region".to_string()),
- country: Some("country".to_string()),
- lat: Some(-6.0),
- lng: Some(-77.0),
- geohash: None,
- });
+ listing.location = None;
listing.images = Some(vec![RadrootsListingImage {
url: "null".to_string(),
size: None,
@@ -1532,14 +1215,12 @@ mod tests {
let mut listing = base_listing();
listing.discounts = None;
listing.images = None;
- listing.location = Some(RadrootsListingLocation {
+ listing.location = Some(RadrootsListingPublicLocation {
primary: "Moyobamba".to_string(),
city: Some("Moyobamba".to_string()),
region: Some("San Martin".to_string()),
country: Some("PE".to_string()),
- lat: None,
- lng: None,
- geohash: Some("6gkzwgjzn".to_string()),
+ geohash: "6gkzw".to_string(),
});
let tags = listing_tags_full(&listing).expect("location tags");
let location = find_tag(&tags, "location").expect("location tag");
@@ -1597,14 +1278,12 @@ mod tests {
fn listing_tags_location_handles_partial_optional_components() {
let mut listing = base_listing();
listing.discounts = None;
- listing.location = Some(RadrootsListingLocation {
+ listing.location = Some(RadrootsListingPublicLocation {
primary: "Moyobamba".to_string(),
city: Some(" ".to_string()),
region: Some("San Martin".to_string()),
country: Some(" ".to_string()),
- lat: Some(-6.03),
- lng: Some(-76.97),
- geohash: None,
+ geohash: "9q8yy".to_string(),
});
let tags = listing_tags(&listing).expect("listing tags");
@@ -1614,14 +1293,12 @@ mod tests {
assert_eq!(location.get(2).map(|v| v.as_str()), Some("San Martin"));
assert_eq!(location.len(), 3);
- listing.location = Some(RadrootsListingLocation {
+ listing.location = Some(RadrootsListingPublicLocation {
primary: "Moyobamba".to_string(),
city: Some("Moyobamba".to_string()),
region: Some(" ".to_string()),
country: Some("PE".to_string()),
- lat: Some(-6.03),
- lng: Some(-76.97),
- geohash: None,
+ geohash: "9q8yy".to_string(),
});
let tags = listing_tags(&listing).expect("listing tags");
let location = find_tag(&tags, "location").expect("location tag");
diff --git a/crates/events_codec/src/plot/mod.rs b/crates/events_codec/src/plot/mod.rs
@@ -6,9 +6,8 @@ mod tests {
use crate::error::EventEncodeError;
use crate::plot::encode::{plot_address, plot_build_tags};
use radroots_events::{
- farm::{
- RadrootsFarmRef, RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon,
- },
+ farm::RadrootsFarmRef,
+ gcs::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon},
plot::{RadrootsPlot, RadrootsPlotLocation},
};
diff --git a/crates/events_codec/src/resource_area/mod.rs b/crates/events_codec/src/resource_area/mod.rs
@@ -13,9 +13,7 @@ mod tests {
resource_area_stewards_list_set,
};
use radroots_events::farm::RadrootsFarmRef;
- use radroots_events::farm::{
- RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon,
- };
+ use radroots_events::gcs::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon};
use radroots_events::plot::RadrootsPlotRef;
use radroots_events::resource_area::{
RadrootsResourceArea, RadrootsResourceAreaLocation, RadrootsResourceAreaRef,
diff --git a/crates/events_codec/tests/domain_encode_non_serde.rs b/crates/events_codec/tests/domain_encode_non_serde.rs
@@ -10,14 +10,12 @@ use radroots_core::{
use radroots_events::{
coop::{RadrootsCoop, RadrootsCoopLocation, RadrootsCoopRef},
document::{RadrootsDocument, RadrootsDocumentSubject},
- farm::{
- RadrootsFarm, RadrootsFarmLocation, RadrootsFarmRef, RadrootsGcsLocation,
- RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon,
- },
+ farm::{RadrootsFarm, RadrootsFarmPublicLocation, RadrootsFarmRef},
+ gcs::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon},
ids::{RadrootsDTag, RadrootsInventoryBinId},
listing::{
RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
- RadrootsListingDeliveryMethod, RadrootsListingLocation, RadrootsListingProduct,
+ RadrootsListingDeliveryMethod, RadrootsListingProduct, RadrootsListingPublicLocation,
},
plot::{RadrootsPlot, RadrootsPlotLocation, RadrootsPlotRef},
resource_area::{RadrootsResourceArea, RadrootsResourceAreaLocation, RadrootsResourceAreaRef},
@@ -126,12 +124,12 @@ fn sample_farm() -> RadrootsFarm {
website: None,
picture: None,
banner: None,
- location: Some(RadrootsFarmLocation {
- primary: None,
- city: None,
- region: None,
- country: None,
- gcs: Some(sample_gcs("9q8yy")),
+ location: Some(RadrootsFarmPublicLocation {
+ primary: "Test Farm".to_string(),
+ city: Some("Santa Cruz".to_string()),
+ region: Some("California".to_string()),
+ country: Some("US".to_string()),
+ geohash: "9q8yy".to_string(),
}),
tags: Some(vec!["orchard".to_string()]),
}
@@ -472,27 +470,20 @@ fn farm_encode_and_list_set_paths() {
assert!(matches!(err, EventEncodeError::EmptyRequiredField("name")));
let mut farm = sample_farm();
- farm.location
- .as_mut()
- .expect("location")
- .gcs
- .as_mut()
- .expect("gcs")
- .geohash = " ".to_string();
+ farm.location.as_mut().expect("location").geohash = " ".to_string();
let err = farm_build_tags(&farm).expect_err("empty geohash");
assert!(matches!(
err,
- EventEncodeError::EmptyRequiredField("location.gcs.geohash")
+ EventEncodeError::EmptyRequiredField("location.geohash")
));
let mut farm = sample_farm();
- farm.location.as_mut().expect("location").gcs = None;
- let tags = farm_build_tags(&farm).expect("farm location without geo");
- assert!(
- !tags
- .iter()
- .any(|tag| tag.first().map(|v| v.as_str()) == Some("g"))
- );
+ farm.location.as_mut().expect("location").geohash = "9q8yy6".to_string();
+ let err = farm_build_tags(&farm).expect_err("invalid public geohash");
+ assert!(matches!(
+ err,
+ EventEncodeError::InvalidField("location.geohash")
+ ));
let tags = farm_ref_tags(&RadrootsFarmRef {
pubkey: VALID_PUBKEY.to_string(),
@@ -691,55 +682,50 @@ fn listing_encode_paths() {
}));
let mut listing_with_geohash = sample_listing();
- listing_with_geohash.location = Some(RadrootsListingLocation {
+ listing_with_geohash.location = Some(RadrootsListingPublicLocation {
primary: "Origin".to_string(),
- city: None,
- region: None,
- country: None,
- lat: None,
- lng: None,
- geohash: Some("6gkzwgjzn".to_string()),
+ city: Some("Town".to_string()),
+ region: Some("Region".to_string()),
+ country: Some("PE".to_string()),
+ geohash: "6gkzw".to_string(),
});
let decoded_tags = listing_tags_with_options(
&listing_with_geohash,
ListingTagOptions {
include_geohash: false,
- include_gps: true,
..ListingTagOptions::default()
},
)
- .expect("listing tags with decoded geohash");
- assert!(decoded_tags.iter().any(|tag| {
- tag.first().map(|v| v.as_str()) == Some("l") && tag.get(2).map(|v| v.as_str()) == Some("dd")
- }));
+ .expect("listing tags without geohash tag");
+ assert!(
+ !decoded_tags
+ .iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("g"))
+ );
let mut listing_with_shared_geohash = sample_listing();
- listing_with_shared_geohash.location = Some(RadrootsListingLocation {
+ listing_with_shared_geohash.location = Some(RadrootsListingPublicLocation {
primary: "Origin".to_string(),
- city: None,
- region: None,
- country: None,
- lat: None,
- lng: None,
- geohash: Some("6gkzwgjzn".to_string()),
+ city: Some("Town".to_string()),
+ region: Some("Region".to_string()),
+ country: Some("PE".to_string()),
+ geohash: "6gkzw".to_string(),
});
let shared_geohash_tags =
listing_tags_with_options(&listing_with_shared_geohash, ListingTagOptions::default())
.expect("listing tags with shared geohash");
assert!(shared_geohash_tags.iter().any(|tag| {
tag.first().map(|v| v.as_str()) == Some("g")
- && tag.get(1).map(|v| v.as_str()) == Some("6gkzwgjzn")
+ && tag.get(1).map(|v| v.as_str()) == Some("6gkzw")
}));
let mut listing_without_coordinates = sample_listing();
- listing_without_coordinates.location = Some(RadrootsListingLocation {
+ listing_without_coordinates.location = Some(RadrootsListingPublicLocation {
primary: "Origin".to_string(),
- city: None,
- region: None,
- country: None,
- lat: None,
- lng: None,
- geohash: None,
+ city: Some("Town".to_string()),
+ region: Some("Region".to_string()),
+ country: Some("PE".to_string()),
+ geohash: "6gkzw".to_string(),
});
let no_coordinates_tags =
listing_tags_with_options(&listing_without_coordinates, ListingTagOptions::default())
@@ -749,11 +735,10 @@ fn listing_encode_paths() {
.iter()
.any(|tag| tag.first().map(|v| v.as_str()) == Some("L"))
);
- assert!(
- !no_coordinates_tags
- .iter()
- .any(|tag| tag.first().map(|v| v.as_str()) == Some("g"))
- );
+ assert!(no_coordinates_tags.iter().any(|tag| {
+ tag.first().map(|v| v.as_str()) == Some("g")
+ && tag.get(1).map(|v| v.as_str()) == Some("6gkzw")
+ }));
let mut listing_with_blank_optionals = sample_listing();
listing_with_blank_optionals.product.summary = Some(" ".to_string());
@@ -761,18 +746,31 @@ fn listing_encode_paths() {
listing_with_blank_optionals.product.location = Some(" ".to_string());
listing_with_blank_optionals.product.profile = Some("null".to_string());
listing_with_blank_optionals.product.year = Some(" ".to_string());
- listing_with_blank_optionals.location = Some(RadrootsListingLocation {
+ listing_with_blank_optionals.location = Some(RadrootsListingPublicLocation {
primary: " ".to_string(),
city: Some(" ".to_string()),
region: Some("null".to_string()),
country: Some(" ".to_string()),
- lat: None,
- lng: None,
- geohash: None,
+ geohash: "6gkzw".to_string(),
});
let blank_optional_tags =
listing_tags_with_options(&listing_with_blank_optionals, ListingTagOptions::default())
- .expect("listing tags with blank optional values");
+ .expect_err("blank public location is invalid");
+ assert!(matches!(
+ blank_optional_tags,
+ EventEncodeError::EmptyRequiredField("location.primary")
+ ));
+ let mut listing_with_blank_product_optionals = sample_listing();
+ listing_with_blank_product_optionals.product.summary = Some(" ".to_string());
+ listing_with_blank_product_optionals.product.process = Some("null".to_string());
+ listing_with_blank_product_optionals.product.location = Some(" ".to_string());
+ listing_with_blank_product_optionals.product.profile = Some("null".to_string());
+ listing_with_blank_product_optionals.product.year = Some(" ".to_string());
+ let blank_optional_tags = listing_tags_with_options(
+ &listing_with_blank_product_optionals,
+ ListingTagOptions::default(),
+ )
+ .expect("listing tags with blank product optional values");
assert!(
!blank_optional_tags
.iter()
@@ -784,26 +782,24 @@ fn listing_encode_paths() {
.any(|tag| tag.first().map(|v| v.as_str()) == Some("location"))
);
- let mut listing_no_gps = sample_listing();
- listing_no_gps.location = Some(RadrootsListingLocation {
+ let mut listing_without_geohash = sample_listing();
+ listing_without_geohash.location = Some(RadrootsListingPublicLocation {
primary: "Origin".to_string(),
- city: None,
- region: None,
- country: None,
- lat: Some(37.0),
- lng: Some(-122.0),
- geohash: None,
+ city: Some("Town".to_string()),
+ region: Some("Region".to_string()),
+ country: Some("PE".to_string()),
+ geohash: "6gkzw".to_string(),
});
- let no_gps_tags = listing_tags_with_options(
- &listing_no_gps,
+ let without_geohash_tags = listing_tags_with_options(
+ &listing_without_geohash,
ListingTagOptions {
- include_gps: false,
+ include_geohash: false,
..ListingTagOptions::default()
},
)
- .expect("listing tags without gps labels");
+ .expect("listing tags without geohash");
assert!(
- !no_gps_tags
+ !without_geohash_tags
.iter()
.any(|tag| tag.first().map(|v| v.as_str()) == Some("L"))
);
diff --git a/crates/events_codec/tests/listing.rs b/crates/events_codec/tests/listing.rs
@@ -15,7 +15,7 @@ use radroots_events::{
listing::{
RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
RadrootsListingDeliveryMethod, RadrootsListingImage, RadrootsListingImageSize,
- RadrootsListingLocation, RadrootsListingProduct, RadrootsListingStatus,
+ RadrootsListingProduct, RadrootsListingPublicLocation, RadrootsListingStatus,
},
plot::RadrootsPlotRef,
resource_area::RadrootsResourceAreaRef,
@@ -177,14 +177,12 @@ fn sample_listing_full(d_tag: &str) -> RadrootsListing {
inventory_available: None,
availability: None,
delivery_method: None,
- location: Some(RadrootsListingLocation {
+ location: Some(RadrootsListingPublicLocation {
primary: "Moyobamba".to_string(),
city: Some("Moyobamba".to_string()),
region: Some("San Martin".to_string()),
country: Some("PE".to_string()),
- lat: Some(-6.0346),
- lng: Some(-76.9714),
- geohash: None,
+ geohash: "9q8yy".to_string(),
}),
images: Some(vec![RadrootsListingImage {
url: "http://example.com/widget.jpg".to_string(),
@@ -587,13 +585,8 @@ fn listing_from_event_covers_trade_location_delivery_and_image_paths() {
let mut tags = sample_listing_tags();
tags.push(vec!["location".to_string(), "Farm shelf".to_string()]);
let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
- assert_eq!(
- decoded
- .location
- .as_ref()
- .map(|location| location.primary.as_str()),
- Some("Farm shelf")
- );
+ assert_eq!(decoded.product.location.as_deref(), Some("Farm shelf"));
+ assert!(decoded.location.is_none());
let mut tags = sample_listing_tags();
tags.push(vec!["location".to_string(), "Farm shelf".to_string()]);
@@ -604,7 +597,7 @@ fn listing_from_event_covers_trade_location_delivery_and_image_paths() {
"San Martin".to_string(),
"PE".to_string(),
]);
- tags.push(vec!["g".to_string(), "6gkzwgjzn".to_string()]);
+ tags.push(vec!["g".to_string(), "6gkzw".to_string()]);
let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
assert_eq!(decoded.product.location.as_deref(), Some("Farm shelf"));
assert_eq!(
@@ -612,10 +605,10 @@ fn listing_from_event_covers_trade_location_delivery_and_image_paths() {
(
location.primary.as_str(),
location.city.as_deref(),
- location.geohash.as_deref(),
+ location.geohash.as_str(),
)
}),
- Some(("Peru", Some("Moyobamba"), Some("6gkzwgjzn")))
+ Some(("Peru", Some("Moyobamba"), "6gkzw"))
);
let mut tags = sample_listing_tags();
@@ -643,7 +636,6 @@ fn listing_from_event_covers_trade_location_delivery_and_image_paths() {
tags.push(vec!["expires_at".to_string(), "1740".to_string()]);
tags.push(vec!["delivery".to_string(), "pickup".to_string()]);
tags.push(vec!["image".to_string(), " ".to_string()]);
- tags.push(vec!["g".to_string(), " ".to_string()]);
tags.push(vec![
"image".to_string(),
"https://example.test/a.jpg".to_string(),
@@ -992,24 +984,18 @@ fn listing_build_tags_includes_listing_fields() {
.iter()
.filter(|t| t.get(0).map(|s| s.as_str()) == Some("g"))
.collect();
- assert!(!g_tags.is_empty());
- let full_len = g_tags[0][1].len();
- assert_eq!(g_tags.len(), full_len);
- for (idx, tag) in g_tags.iter().enumerate() {
- assert_eq!(tag[1].len(), full_len - idx);
- }
- assert!(tags.iter().any(|t| {
- t.get(0).map(|s| s.as_str()) == Some("L") && t.get(1).map(|s| s.as_str()) == Some("dd.lat")
- }));
- assert!(tags.iter().any(|t| {
- t.get(0).map(|s| s.as_str()) == Some("L") && t.get(1).map(|s| s.as_str()) == Some("dd.lon")
- }));
- assert!(tags.iter().any(|t| {
- t.get(0).map(|s| s.as_str()) == Some("l") && t.get(2).map(|s| s.as_str()) == Some("dd.lat")
- }));
- assert!(tags.iter().any(|t| {
- t.get(0).map(|s| s.as_str()) == Some("l") && t.get(2).map(|s| s.as_str()) == Some("dd.lon")
- }));
+ assert_eq!(g_tags.len(), 1);
+ assert_eq!(g_tags[0][1].len(), 5);
+ assert!(
+ !tags
+ .iter()
+ .any(|t| t.get(0).map(|s| s.as_str()) == Some("L"))
+ );
+ assert!(
+ !tags
+ .iter()
+ .any(|t| t.get(0).map(|s| s.as_str()) == Some("l"))
+ );
assert!(tags.iter().any(|t| {
t.get(0).map(|s| s.as_str()) == Some("image")
@@ -1116,14 +1102,12 @@ fn listing_build_tags_ignores_null_strings() {
listing.product.location = Some("null".to_string());
listing.product.profile = Some("null".to_string());
listing.product.year = Some("null".to_string());
- listing.location = Some(RadrootsListingLocation {
+ listing.location = Some(RadrootsListingPublicLocation {
primary: "Moyobamba".to_string(),
city: Some("null".to_string()),
region: Some("San Martin".to_string()),
country: Some("null".to_string()),
- lat: Some(-6.0346),
- lng: Some(-76.9714),
- geohash: None,
+ geohash: "9q8yy".to_string(),
});
listing.images = Some(vec![RadrootsListingImage {
url: "null".to_string(),
@@ -1141,34 +1125,31 @@ fn listing_build_tags_ignores_null_strings() {
#[test]
fn listing_tags_with_options_cover_location_fallback_paths() {
let mut geohash_only = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
- geohash_only.location = Some(RadrootsListingLocation {
+ geohash_only.location = Some(RadrootsListingPublicLocation {
primary: "Moyobamba".to_string(),
- city: None,
+ city: Some("Moyobamba".to_string()),
region: None,
country: None,
- lat: None,
- lng: None,
- geohash: Some("6gkzwgjzn".to_string()),
+ geohash: "6gkzw".to_string(),
});
let tags = listing_tags_with_options(&geohash_only, ListingTagOptions::default()).unwrap();
assert!(
tags.iter()
.any(|tag| tag.get(0).map(|value| value.as_str()) == Some("g"))
);
- assert!(tags.iter().any(|tag| {
- tag.get(0).map(|value| value.as_str()) == Some("l")
- && tag.get(2).map(|value| value.as_str()) == Some("dd")
- }));
+ assert!(
+ !tags
+ .iter()
+ .any(|tag| tag.get(0).map(|value| value.as_str()) == Some("l"))
+ );
let mut no_coordinates = sample_listing("AAAAAAAAAAAAAAAAAAAAAQ");
- no_coordinates.location = Some(RadrootsListingLocation {
+ no_coordinates.location = Some(RadrootsListingPublicLocation {
primary: "Moyobamba".to_string(),
- city: None,
+ city: Some("Moyobamba".to_string()),
region: None,
country: None,
- lat: None,
- lng: None,
- geohash: None,
+ geohash: "9q8yy".to_string(),
});
let tags = listing_tags_with_options(&no_coordinates, ListingTagOptions::default()).unwrap();
assert!(
@@ -1177,25 +1158,21 @@ fn listing_tags_with_options_cover_location_fallback_paths() {
.any(|tag| tag.get(0).map(|value| value.as_str()) == Some("L"))
);
assert!(
- !tags
- .iter()
+ tags.iter()
.any(|tag| tag.get(0).map(|value| value.as_str()) == Some("g"))
);
- let mut no_gps = sample_listing("AAAAAAAAAAAAAAAAAAAAAw");
- no_gps.location = Some(RadrootsListingLocation {
+ let mut without_geohash = sample_listing("AAAAAAAAAAAAAAAAAAAAAw");
+ without_geohash.location = Some(RadrootsListingPublicLocation {
primary: "Moyobamba".to_string(),
- city: None,
+ city: Some("Moyobamba".to_string()),
region: None,
country: None,
- lat: Some(-6.0346),
- lng: Some(-76.9714),
- geohash: None,
+ geohash: "9q8yy".to_string(),
});
let tags = listing_tags_with_options(
- &no_gps,
+ &without_geohash,
ListingTagOptions {
- include_gps: false,
..ListingTagOptions::default()
},
)
diff --git a/crates/events_codec/tests/structured_decode.rs b/crates/events_codec/tests/structured_decode.rs
@@ -6,10 +6,8 @@ mod test_fixtures;
use radroots_core::{RadrootsCoreDecimal, RadrootsCoreQuantity, RadrootsCoreUnit};
use radroots_events::coop::RadrootsCoop;
use radroots_events::document::{RadrootsDocument, RadrootsDocumentSubject};
-use radroots_events::farm::{
- RadrootsFarm, RadrootsFarmRef, RadrootsGcsLocation, RadrootsGeoJsonPoint,
- RadrootsGeoJsonPolygon,
-};
+use radroots_events::farm::{RadrootsFarm, RadrootsFarmRef};
+use radroots_events::gcs::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon};
use radroots_events::kinds::{
KIND_COOP, KIND_DOCUMENT, KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA, KIND_RESOURCE_HARVEST_CAP,
};
diff --git a/crates/events_codec/tests/structured_encode_default.rs b/crates/events_codec/tests/structured_encode_default.rs
@@ -7,10 +7,8 @@ use radroots_core::{
};
use radroots_events::coop::{RadrootsCoop, RadrootsCoopLocation, RadrootsCoopRef};
use radroots_events::document::{RadrootsDocument, RadrootsDocumentSubject};
-use radroots_events::farm::{
- RadrootsFarm, RadrootsFarmLocation, RadrootsFarmRef, RadrootsGcsLocation, RadrootsGeoJsonPoint,
- RadrootsGeoJsonPolygon,
-};
+use radroots_events::farm::{RadrootsFarm, RadrootsFarmPublicLocation, RadrootsFarmRef};
+use radroots_events::gcs::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon};
use radroots_events::ids::{RadrootsDTag, RadrootsInventoryBinId};
use radroots_events::kinds::{
KIND_COOP, KIND_DOCUMENT, KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA, KIND_RESOURCE_HARVEST_CAP,
@@ -162,12 +160,12 @@ fn sample_farm() -> RadrootsFarm {
website: None,
picture: None,
banner: None,
- location: Some(RadrootsFarmLocation {
- primary: Some("farm".to_string()),
- city: None,
- region: None,
+ location: Some(RadrootsFarmPublicLocation {
+ primary: "farm".to_string(),
+ city: Some("Town".to_string()),
+ region: Some("Region".to_string()),
country: None,
- gcs: Some(sample_gcs()),
+ geohash: "9q8yy".to_string(),
}),
tags: Some(vec!["organic".to_string(), " ".to_string()]),
}
@@ -377,12 +375,12 @@ fn structured_build_tags_cover_optional_and_error_paths() {
website: None,
picture: None,
banner: None,
- location: Some(RadrootsFarmLocation {
- primary: Some("farm".to_string()),
- city: None,
- region: None,
+ location: Some(RadrootsFarmPublicLocation {
+ primary: "farm".to_string(),
+ city: Some("Town".to_string()),
+ region: Some("Region".to_string()),
country: None,
- gcs: Some(sample_gcs()),
+ geohash: "9q8yy".to_string(),
}),
tags: Some(vec!["organic".to_string(), " ".to_string()]),
};
@@ -396,22 +394,15 @@ fn structured_build_tags_cover_optional_and_error_paths() {
assert!(farm_tags.iter().any(|tag| tag[0] == "g"));
let mut invalid_farm = farm.clone();
- invalid_farm
- .location
- .as_mut()
- .unwrap()
- .gcs
- .as_mut()
- .unwrap()
- .geohash = " ".to_string();
+ invalid_farm.location.as_mut().unwrap().geohash = " ".to_string();
let err = farm_build_tags(&invalid_farm).unwrap_err();
assert!(matches!(
err,
- EventEncodeError::EmptyRequiredField("location.gcs.geohash")
+ EventEncodeError::EmptyRequiredField("location.geohash")
));
let mut string_only_farm = farm.clone();
- string_only_farm.location.as_mut().unwrap().gcs = None;
+ string_only_farm.location = None;
let string_only_tags = farm_build_tags(&string_only_farm).unwrap();
assert!(
!string_only_tags
diff --git a/crates/events_codec/tests/tag_builders.rs b/crates/events_codec/tests/tag_builders.rs
@@ -11,11 +11,9 @@ use radroots_events::app_data::RadrootsAppData;
use radroots_events::comment::RadrootsComment;
use radroots_events::coop::RadrootsCoop;
use radroots_events::document::{RadrootsDocument, RadrootsDocumentSubject};
-use radroots_events::farm::{
- RadrootsFarm, RadrootsFarmRef, RadrootsGcsLocation, RadrootsGeoJsonPoint,
- RadrootsGeoJsonPolygon,
-};
+use radroots_events::farm::{RadrootsFarm, RadrootsFarmRef};
use radroots_events::follow::{RadrootsFollow, RadrootsFollowProfile};
+use radroots_events::gcs::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon};
use radroots_events::geochat::RadrootsGeoChat;
use radroots_events::gift_wrap::{RadrootsGiftWrap, RadrootsGiftWrapRecipient};
use radroots_events::ids::{RadrootsDTag, RadrootsInventoryBinId};
@@ -30,7 +28,7 @@ use radroots_events::list::{RadrootsList, RadrootsListEntry};
use radroots_events::list_set::RadrootsListSet;
use radroots_events::listing::{
RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, RadrootsListingImage,
- RadrootsListingImageSize, RadrootsListingLocation, RadrootsListingProduct,
+ RadrootsListingImageSize, RadrootsListingProduct, RadrootsListingPublicLocation,
RadrootsListingStatus,
};
use radroots_events::message::{RadrootsMessage, RadrootsMessageRecipient};
@@ -479,14 +477,12 @@ fn listing_and_message_builders_cover_optional_shapes() {
listing.product.location = Some("Moyobamba".to_string());
listing.product.profile = Some("fruity".to_string());
listing.product.year = Some("2024".to_string());
- listing.location = Some(RadrootsListingLocation {
+ listing.location = Some(RadrootsListingPublicLocation {
primary: "Moyobamba".to_string(),
city: Some("Moyobamba".to_string()),
region: Some("San Martin".to_string()),
country: Some("PE".to_string()),
- lat: Some(-6.03),
- lng: Some(-76.97),
- geohash: None,
+ geohash: "9q8yy".to_string(),
});
listing.images = Some(vec![RadrootsListingImage {
url: cdn_url("a.jpg"),
@@ -542,31 +538,27 @@ fn listing_and_message_builders_cover_optional_shapes() {
}));
let mut listing_geohash_only = listing_with_trade.clone();
- listing_geohash_only.location = Some(RadrootsListingLocation {
+ listing_geohash_only.location = Some(RadrootsListingPublicLocation {
primary: "Moyobamba".to_string(),
- city: None,
+ city: Some("Moyobamba".to_string()),
region: None,
country: None,
- lat: None,
- lng: None,
- geohash: Some("6gkzwgjzn".to_string()),
+ geohash: "6gkzw".to_string(),
});
let geohash_tags =
listing_tags_with_options(&listing_geohash_only, ListingTagOptions::default()).unwrap();
assert!(geohash_tags.iter().any(|tag| {
tag.first().map(|v| v.as_str()) == Some("g")
- && tag.get(1).map(|v| v.as_str()) == Some("6gkzwgjzn")
+ && tag.get(1).map(|v| v.as_str()) == Some("6gkzw")
}));
let mut listing_no_coordinates = listing_with_trade.clone();
- listing_no_coordinates.location = Some(RadrootsListingLocation {
+ listing_no_coordinates.location = Some(RadrootsListingPublicLocation {
primary: "Moyobamba".to_string(),
- city: None,
+ city: Some("Moyobamba".to_string()),
region: None,
country: None,
- lat: None,
- lng: None,
- geohash: None,
+ geohash: "9q8yy".to_string(),
});
let no_coordinates_tags =
listing_tags_with_options(&listing_no_coordinates, ListingTagOptions::default()).unwrap();
@@ -576,37 +568,31 @@ fn listing_and_message_builders_cover_optional_shapes() {
.any(|tag| tag.first().map(|v| v.as_str()) == Some("L"))
);
- let no_gps_tags = listing_tags_with_options(
+ let without_private_location_tags = listing_tags_with_options(
&listing_with_trade,
ListingTagOptions {
- include_gps: false,
..ListingTagOptions::default()
},
)
.unwrap();
assert!(
- !no_gps_tags
+ !without_private_location_tags
.iter()
.any(|tag| tag.first().map(|v| v.as_str()) == Some("L"))
);
let mut listing_with_empty_primary_location = listing_with_trade.clone();
- listing_with_empty_primary_location.location = Some(RadrootsListingLocation {
+ listing_with_empty_primary_location.location = Some(RadrootsListingPublicLocation {
primary: " null ".to_string(),
- city: None,
+ city: Some("Moyobamba".to_string()),
region: None,
country: None,
- lat: Some(-6.03),
- lng: Some(-76.97),
- geohash: None,
+ geohash: "9q8yy".to_string(),
});
- let no_primary_location_tags =
- listing_tags_with_options(&listing_with_empty_primary_location, trade_options).unwrap();
- assert!(
- !no_primary_location_tags
- .iter()
- .any(|tag| tag.first().map(|v| v.as_str()) == Some("location") && tag.len() > 2)
- );
+ assert!(matches!(
+ listing_tags_with_options(&listing_with_empty_primary_location, trade_options),
+ Err(EventEncodeError::EmptyRequiredField("location.primary"))
+ ));
let mut listing_with_discount_payload = listing_with_trade.clone();
listing_with_discount_payload.discounts = Some(vec![RadrootsCoreDiscount {
diff --git a/crates/replica_sync/src/emit.rs b/crates/replica_sync/src/emit.rs
@@ -9,11 +9,10 @@ use alloc::{
#[cfg(feature = "std")]
use std::collections::BTreeMap;
-use radroots_events::farm::{
- RadrootsFarm, RadrootsFarmLocation, RadrootsFarmRef, RadrootsGcsLocation, RadrootsGeoJsonPoint,
- RadrootsGeoJsonPolygon,
-};
+use radroots_events::farm::{RadrootsFarm, RadrootsFarmPublicLocation, RadrootsFarmRef};
+use radroots_events::gcs::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon};
use radroots_events::kinds::{KIND_FARM, KIND_LIST_SET_GENERIC, KIND_PLOT};
+use radroots_events::location::{has_textual_locality, is_public_geohash5};
use radroots_events::plot::RadrootsPlot;
use radroots_events::profile::{
RADROOTS_PROFILE_TYPE_TAG_KEY, RadrootsProfile, RadrootsProfileType,
@@ -481,26 +480,48 @@ fn load_plots(
fn load_farm_location(
exec: &dyn SqlExecutor,
farm: &Farm,
-) -> Result<Option<RadrootsFarmLocation>, RadrootsReplicaEventsError> {
- let gcs = load_gcs_location_for_farm(exec, &farm.id)?;
- let has_strings = [
- farm.location_primary.as_deref(),
- farm.location_city.as_deref(),
- farm.location_region.as_deref(),
- farm.location_country.as_deref(),
- ]
- .into_iter()
- .flatten()
- .any(|value| !value.trim().is_empty());
- if !has_strings && gcs.is_none() {
+) -> Result<Option<RadrootsFarmPublicLocation>, RadrootsReplicaEventsError> {
+ let Some(gcs) = load_gcs_location_for_farm(exec, &farm.id)? else {
+ return Ok(None);
+ };
+ let Some(geohash) = gcs.geohash.get(..5).map(|value| value.to_ascii_lowercase()) else {
+ return Ok(None);
+ };
+ if !is_public_geohash5(&geohash) {
+ return Ok(None);
+ }
+ let primary = farm
+ .location_primary
+ .as_ref()
+ .filter(|value| !value.trim().is_empty())
+ .cloned()
+ .or_else(|| gcs.gc_name.clone())
+ .or_else(|| gcs.label.clone())
+ .unwrap_or_else(|| geohash.clone());
+ let city = farm.location_city.clone().or_else(|| gcs.gc_name.clone());
+ let region = farm
+ .location_region
+ .clone()
+ .or_else(|| gcs.gc_admin1_name.clone());
+ let country = farm
+ .location_country
+ .clone()
+ .or_else(|| gcs.gc_country_name.clone())
+ .or_else(|| gcs.gc_country_id.clone());
+ if !has_textual_locality(
+ &primary,
+ city.as_deref(),
+ region.as_deref(),
+ country.as_deref(),
+ ) {
return Ok(None);
}
- Ok(Some(RadrootsFarmLocation {
- primary: farm.location_primary.clone(),
- city: farm.location_city.clone(),
- region: farm.location_region.clone(),
- country: farm.location_country.clone(),
- gcs,
+ Ok(Some(RadrootsFarmPublicLocation {
+ primary,
+ city,
+ region,
+ country,
+ geohash,
}))
}
@@ -1886,7 +1907,7 @@ mod tests {
}
#[test]
- fn load_farm_location_preserves_string_only_locations() {
+ fn load_farm_location_omits_string_only_public_locations() {
let exec = SqliteExecutor::open_memory().expect("db");
migrations::run_all_up(&exec).expect("migrations");
let farm_row = farm::create(
@@ -1908,14 +1929,8 @@ mod tests {
.expect("farm")
.result;
- let location = load_farm_location(&exec, &farm_row)
- .expect("location query")
- .expect("string-only location");
- assert_eq!(location.primary.as_deref(), Some("San Francisco, CA"));
- assert_eq!(location.city.as_deref(), Some("San Francisco"));
- assert_eq!(location.region.as_deref(), Some("CA"));
- assert_eq!(location.country.as_deref(), Some("US"));
- assert!(location.gcs.is_none());
+ let location = load_farm_location(&exec, &farm_row).expect("location query");
+ assert!(location.is_none());
}
#[test]
diff --git a/crates/replica_sync/src/geo.rs b/crates/replica_sync/src/geo.rs
@@ -3,7 +3,7 @@ use alloc::vec;
#[cfg(not(feature = "std"))]
use alloc::{string::String, vec::Vec};
-use radroots_events::farm::{RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon};
+use radroots_events::gcs::{RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon};
const EARTH_RADIUS_M: f64 = 6_378_137.0;
diff --git a/crates/replica_sync/src/ingest.rs b/crates/replica_sync/src/ingest.rs
@@ -30,17 +30,20 @@ use radroots_events_codec::list_set::decode as list_set_decode;
use radroots_events_codec::listing::decode as listing_decode;
use radroots_events_codec::plot::decode as plot_decode;
use radroots_events_codec::profile::decode as profile_decode;
+#[cfg(test)]
+use radroots_replica_db::farm_gcs_location;
use radroots_replica_db::{
- farm, farm_gcs_location, farm_member, farm_member_claim, farm_tag, gcs_location,
- nostr_event_head, nostr_profile, plot, plot_gcs_location, plot_tag, trade_product,
+ farm, farm_member, farm_member_claim, farm_tag, gcs_location, nostr_event_head, nostr_profile,
+ plot, plot_gcs_location, plot_tag, trade_product,
};
use radroots_replica_db_schema::farm::{
FarmQueryBindValues, IFarmFields, IFarmFieldsFilter, IFarmFieldsPartial, IFarmFindMany,
IFarmUpdate,
};
+#[cfg(test)]
use radroots_replica_db_schema::farm_gcs_location::{
- FarmGcsLocationQueryBindValues, IFarmGcsLocationDelete, IFarmGcsLocationFields,
- IFarmGcsLocationFieldsFilter, IFarmGcsLocationFindMany, IFarmGcsLocationFindOneArgs,
+ FarmGcsLocationQueryBindValues, IFarmGcsLocationDelete, IFarmGcsLocationFieldsFilter,
+ IFarmGcsLocationFindMany, IFarmGcsLocationFindOneArgs,
};
use radroots_replica_db_schema::farm_member::{
FarmMemberQueryBindValues, IFarmMemberDelete, IFarmMemberFields, IFarmMemberFieldsFilter,
@@ -295,7 +298,7 @@ fn ingest_profile_event(
fn ingest_farm_event(
exec: &dyn SqlExecutor,
event: &RadrootsNostrEvent,
- factory: &dyn RadrootsReplicaIdFactory,
+ _factory: &dyn RadrootsReplicaIdFactory,
) -> Result<RadrootsReplicaIngestOutcome, RadrootsReplicaEventsError> {
let farm = farm_decode::farm_from_event(event.kind, &event.tags, &event.content)?;
let decision = event_head_decision(exec, event)?;
@@ -370,7 +373,6 @@ fn ingest_farm_event(
};
upsert_farm_tags(exec, &farm_id, farm.tags)?;
- upsert_farm_location(exec, &farm_id, location, factory)?;
upsert_event_head(exec, &decision)?;
Ok(RadrootsReplicaIngestOutcome::Applied)
@@ -1120,27 +1122,6 @@ fn upsert_plot_tags(
Ok(())
}
-fn upsert_farm_location(
- exec: &dyn SqlExecutor,
- farm_id: &str,
- location: Option<radroots_events::farm::RadrootsFarmLocation>,
- factory: &dyn RadrootsReplicaIdFactory,
-) -> Result<(), RadrootsReplicaEventsError> {
- clear_farm_locations(exec, farm_id)?;
- if let Some(location) = location
- && let Some(gcs) = location.gcs
- {
- let gcs_id = create_gcs_location(exec, gcs, factory)?;
- let fields = IFarmGcsLocationFields {
- farm_id: farm_id.to_string(),
- gcs_location_id: gcs_id,
- role: ROLE_PRIMARY.to_string(),
- };
- let _ = farm_gcs_location::create(exec, &fields)?;
- }
- Ok(())
-}
-
fn upsert_plot_location(
exec: &dyn SqlExecutor,
plot_id: &str,
@@ -1160,6 +1141,7 @@ fn upsert_plot_location(
Ok(())
}
+#[cfg(test)]
fn clear_farm_locations(
exec: &dyn SqlExecutor,
farm_id: &str,
@@ -1220,7 +1202,7 @@ fn clear_plot_locations(
fn create_gcs_location(
exec: &dyn SqlExecutor,
- gcs: radroots_events::farm::RadrootsGcsLocation,
+ gcs: radroots_events::gcs::RadrootsGcsLocation,
factory: &dyn RadrootsReplicaIdFactory,
) -> Result<String, RadrootsReplicaEventsError> {
let d_tag = factory.new_d_tag();
@@ -1272,7 +1254,7 @@ fn map_gcs_polygon_serialize_error(_err: serde_json::Error) -> RadrootsReplicaEv
#[cfg(test)]
fn serialize_gcs_point(
- point: &radroots_events::farm::RadrootsGeoJsonPoint,
+ point: &radroots_events::gcs::RadrootsGeoJsonPoint,
) -> Result<String, serde_json::Error> {
#[cfg(test)]
if failpoints::take_gcs_point_serialize_error() {
@@ -1282,13 +1264,13 @@ fn serialize_gcs_point(
}
#[cfg(not(test))]
-fn serialize_gcs_point(point: &radroots_events::farm::RadrootsGeoJsonPoint) -> String {
+fn serialize_gcs_point(point: &radroots_events::gcs::RadrootsGeoJsonPoint) -> String {
serde_json::to_string(point).expect("gcs.point serializes")
}
#[cfg(test)]
fn serialize_gcs_polygon(
- polygon: &radroots_events::farm::RadrootsGeoJsonPolygon,
+ polygon: &radroots_events::gcs::RadrootsGeoJsonPolygon,
) -> Result<String, serde_json::Error> {
#[cfg(test)]
if failpoints::take_gcs_polygon_serialize_error() {
@@ -1298,7 +1280,7 @@ fn serialize_gcs_polygon(
}
#[cfg(not(test))]
-fn serialize_gcs_polygon(polygon: &radroots_events::farm::RadrootsGeoJsonPolygon) -> String {
+fn serialize_gcs_polygon(polygon: &radroots_events::gcs::RadrootsGeoJsonPolygon) -> String {
serde_json::to_string(polygon).expect("gcs.polygon serializes")
}
@@ -1431,7 +1413,7 @@ enum ListSetRole {
}
fn unpack_farm_location_strings(
- location: Option<&radroots_events::farm::RadrootsFarmLocation>,
+ location: Option<&radroots_events::farm::RadrootsFarmPublicLocation>,
) -> (
Option<String>,
Option<String>,
@@ -1440,7 +1422,7 @@ fn unpack_farm_location_strings(
) {
match location {
Some(location) => (
- location.primary.clone(),
+ Some(location.primary.clone()),
location.city.clone(),
location.region.clone(),
location.country.clone(),
@@ -1528,10 +1510,8 @@ mod tests {
RadrootsCoreCurrency, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice,
RadrootsCoreUnit,
};
- use radroots_events::farm::{
- RadrootsFarm, RadrootsFarmLocation, RadrootsFarmRef, RadrootsGcsLocation,
- RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon,
- };
+ use radroots_events::farm::{RadrootsFarm, RadrootsFarmPublicLocation, RadrootsFarmRef};
+ use radroots_events::gcs::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon};
use radroots_events::kinds::{KIND_LIST_SET_FOLLOW, KIND_LIST_SET_GENERIC};
use radroots_events::list::RadrootsListEntry;
use radroots_events::list_set::RadrootsListSet;
@@ -1783,7 +1763,7 @@ mod tests {
created_at: u32,
d_tag: &str,
name: &str,
- location: Option<RadrootsFarmLocation>,
+ location: Option<RadrootsFarmPublicLocation>,
tags: Option<Vec<String>>,
) -> RadrootsNostrEvent {
let farm = RadrootsFarm {
@@ -2294,12 +2274,12 @@ mod tests {
10,
farm_d_tag,
"farm-a",
- Some(RadrootsFarmLocation {
- primary: Some("primary".to_string()),
+ Some(RadrootsFarmPublicLocation {
+ primary: "primary".to_string(),
city: Some("city".to_string()),
region: Some("region".to_string()),
country: Some("country".to_string()),
- gcs: Some(sample_gcs(10.0, 20.0, "s0")),
+ geohash: "9q8yy".to_string(),
}),
Some(vec![
"coffee".to_string(),
@@ -2509,12 +2489,12 @@ mod tests {
assert!(parse_farm_list_set_d_tag("farm:AAAAAAAAAAAAAAAAAAAAAA:plots").is_some());
assert_eq!(to_value_opt(Some("x".to_string())), Some(Value::from("x")));
assert_eq!(to_value_opt(None), Some(Value::Null));
- let location = RadrootsFarmLocation {
- primary: Some("p".to_string()),
+ let location = RadrootsFarmPublicLocation {
+ primary: "p".to_string(),
city: Some("c".to_string()),
region: Some("r".to_string()),
country: Some("k".to_string()),
- gcs: Some(sample_gcs(12.0, 22.0, "s2")),
+ geohash: "9q8yy".to_string(),
};
assert_eq!(
unpack_farm_location_strings(Some(&location)).0,
@@ -2806,7 +2786,6 @@ mod tests {
.expect("plot")
.result;
- let _ = upsert_farm_location(&exec, &farm_row.id, None, &FixedFactory).expect("farm none");
let _ = upsert_plot_location(&exec, &plot_row.id, None, &FixedFactory).expect("plot none");
}
@@ -2856,63 +2835,6 @@ mod tests {
.is_ok()
);
- let not_found_farm_locations = DeleteErrorExecutor {
- inner: &exec,
- table_name: "farm_gcs_location",
- err: SqlError::NotFound("farm_gcs_location".to_string()),
- };
- assert!(
- upsert_farm_location(
- ¬_found_farm_locations,
- &farm_id,
- Some(RadrootsFarmLocation {
- primary: None,
- city: None,
- region: None,
- country: None,
- gcs: Some(sample_gcs(1.0, 2.0, "s4")),
- }),
- &FixedFactory,
- )
- .is_ok()
- );
- assert!(
- upsert_farm_location(
- &exec,
- &farm_id,
- Some(RadrootsFarmLocation {
- primary: Some("manual".to_string()),
- city: Some("San Francisco".to_string()),
- region: Some("CA".to_string()),
- country: Some("US".to_string()),
- gcs: None,
- }),
- &FixedFactory,
- )
- .is_ok()
- );
- assert!(
- farm_gcs_location::find_many(&exec, &IFarmGcsLocationFindMany { filter: None })
- .expect("farm locations after string-only upsert")
- .results
- .is_empty()
- );
- assert!(
- upsert_farm_location(
- &exec,
- &farm_id,
- Some(RadrootsFarmLocation {
- primary: None,
- city: None,
- region: None,
- country: None,
- gcs: Some(sample_gcs(2.0, 3.0, "s6")),
- }),
- &FixedFactory,
- )
- .is_ok()
- );
-
let not_found_plot_locations = DeleteErrorExecutor {
inner: &exec,
table_name: "plot_gcs_location",
@@ -3012,27 +2934,6 @@ mod tests {
upsert_plot_tags(&internal_plot_tags, &plot_id, Some(vec!["x".to_string()])).is_err()
);
- let internal_farm_locations = DeleteErrorExecutor {
- inner: &exec,
- table_name: "farm_gcs_location",
- err: SqlError::Internal,
- };
- assert!(
- upsert_farm_location(
- &internal_farm_locations,
- &farm_id,
- Some(RadrootsFarmLocation {
- primary: None,
- city: None,
- region: None,
- country: None,
- gcs: Some(sample_gcs(2.0, 3.0, "s6")),
- }),
- &FixedFactory,
- )
- .is_err()
- );
-
let internal_plot_locations = DeleteErrorExecutor {
inner: &exec,
table_name: "plot_gcs_location",
@@ -3122,12 +3023,12 @@ mod tests {
51,
farm_d_tag,
"pass-farm",
- Some(RadrootsFarmLocation {
- primary: Some("primary".to_string()),
+ Some(RadrootsFarmPublicLocation {
+ primary: "primary".to_string(),
city: Some("city".to_string()),
region: Some("region".to_string()),
country: Some("country".to_string()),
- gcs: Some(sample_gcs(10.0, 20.0, "s0")),
+ geohash: "9q8yy".to_string(),
}),
Some(vec!["coffee".to_string(), "coffee".to_string()]),
);
@@ -3252,12 +3153,12 @@ mod tests {
60,
farm_d_tag,
"wrapper-farm",
- Some(RadrootsFarmLocation {
- primary: Some("primary".to_string()),
+ Some(RadrootsFarmPublicLocation {
+ primary: "primary".to_string(),
city: None,
region: None,
country: None,
- gcs: Some(sample_gcs(10.0, 20.0, "s0")),
+ geohash: "9q8yy".to_string(),
}),
Some(vec!["coffee".to_string()]),
);
@@ -3369,12 +3270,12 @@ mod tests {
71,
farm_d_tag,
"txn-farm",
- Some(RadrootsFarmLocation {
- primary: Some("primary".to_string()),
+ Some(RadrootsFarmPublicLocation {
+ primary: "primary".to_string(),
city: None,
region: None,
country: None,
- gcs: Some(sample_gcs(12.0, 22.0, "s2")),
+ geohash: "9q8yy".to_string(),
}),
Some(vec!["coffee".to_string()]),
);
@@ -3421,21 +3322,6 @@ mod tests {
create_gcs_location(&pass_txn, sample_gcs(14.0, 24.0, "s4"), &FixedFactory).is_ok()
);
assert!(
- upsert_farm_location(
- &pass_txn,
- &farm_row.id,
- Some(RadrootsFarmLocation {
- primary: Some("primary".to_string()),
- city: None,
- region: None,
- country: None,
- gcs: Some(sample_gcs(15.0, 25.0, "s5")),
- }),
- &FixedFactory,
- )
- .is_ok()
- );
- assert!(
upsert_plot_location(
&pass_txn,
&plot_id,
@@ -3487,21 +3373,6 @@ mod tests {
assert!(clear_plot_locations(&txn, "plot-id").is_err());
assert!(create_gcs_location(&txn, sample_gcs(14.0, 24.0, "s4"), &FixedFactory).is_err());
assert!(
- upsert_farm_location(
- &txn,
- "farm-id",
- Some(RadrootsFarmLocation {
- primary: Some("primary".to_string()),
- city: None,
- region: None,
- country: None,
- gcs: Some(sample_gcs(15.0, 25.0, "s5")),
- }),
- &FixedFactory,
- )
- .is_err()
- );
- assert!(
upsert_plot_location(
&txn,
"plot-id",
@@ -3617,12 +3488,12 @@ mod tests {
90,
farm_d_tag,
"farm-seed",
- Some(RadrootsFarmLocation {
- primary: Some("primary".to_string()),
+ Some(RadrootsFarmPublicLocation {
+ primary: "primary".to_string(),
city: Some("city".to_string()),
region: Some("region".to_string()),
country: Some("country".to_string()),
- gcs: Some(sample_gcs(10.0, 20.0, "s0")),
+ geohash: "9q8yy".to_string(),
}),
Some(vec!["seed".to_string()]),
);
@@ -3710,12 +3581,12 @@ mod tests {
95,
farm_d_tag,
"farm-gcs",
- Some(RadrootsFarmLocation {
- primary: Some("primary".to_string()),
+ Some(RadrootsFarmPublicLocation {
+ primary: "primary".to_string(),
city: None,
region: None,
country: None,
- gcs: Some(sample_gcs(11.0, 21.0, "s1")),
+ geohash: "9q8yy".to_string(),
}),
None,
);
@@ -3732,12 +3603,12 @@ mod tests {
96,
farm_d_tag,
"farm-rel",
- Some(RadrootsFarmLocation {
- primary: Some("primary".to_string()),
+ Some(RadrootsFarmPublicLocation {
+ primary: "primary".to_string(),
city: None,
region: None,
country: None,
- gcs: Some(sample_gcs(12.0, 22.0, "s2")),
+ geohash: "9q8yy".to_string(),
}),
None,
);
@@ -3767,12 +3638,12 @@ mod tests {
98,
farm_d_tag,
"farm-bad-point",
- Some(RadrootsFarmLocation {
- primary: Some("primary".to_string()),
+ Some(RadrootsFarmPublicLocation {
+ primary: "primary".to_string(),
city: None,
region: None,
country: None,
- gcs: Some(bad_point),
+ geohash: "9q8yy".to_string(),
}),
None,
);
@@ -3786,12 +3657,12 @@ mod tests {
99,
farm_d_tag,
"farm-bad-polygon",
- Some(RadrootsFarmLocation {
- primary: Some("primary".to_string()),
+ Some(RadrootsFarmPublicLocation {
+ primary: "primary".to_string(),
city: None,
region: None,
country: None,
- gcs: Some(bad_polygon),
+ geohash: "9q8yy".to_string(),
}),
None,
);
@@ -4198,48 +4069,6 @@ mod tests {
.is_err()
);
- let farm_gcs_insert_fail = QueryFailExecutor {
- inner: &exec,
- needle: "insert into gcs_location",
- err: SqlError::Internal,
- };
- assert!(
- upsert_farm_location(
- &farm_gcs_insert_fail,
- &farm_id,
- Some(RadrootsFarmLocation {
- primary: Some("primary".to_string()),
- city: None,
- region: None,
- country: None,
- gcs: Some(sample_gcs(31.0, 41.0, "s8")),
- }),
- &FixedFactory,
- )
- .is_err()
- );
-
- let farm_rel_insert_fail = QueryFailExecutor {
- inner: &exec,
- needle: "insert into farm_gcs_location",
- err: SqlError::Internal,
- };
- assert!(
- upsert_farm_location(
- &farm_rel_insert_fail,
- &farm_id,
- Some(RadrootsFarmLocation {
- primary: Some("primary".to_string()),
- city: None,
- region: None,
- country: None,
- gcs: Some(sample_gcs(32.0, 42.0, "s9")),
- }),
- &FixedFactory,
- )
- .is_err()
- );
-
let plot_gcs_insert_fail = QueryFailExecutor {
inner: &exec,
needle: "insert into gcs_location",
diff --git a/crates/replica_sync/src/tests.rs b/crates/replica_sync/src/tests.rs
@@ -2,7 +2,7 @@ use crate::{
RADROOTS_REPLICA_TRANSFER_VERSION, RadrootsReplicaFarmSelector, RadrootsReplicaSyncRequest,
radroots_replica_sync_all,
};
-use radroots_events::farm::{RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon};
+use radroots_events::gcs::{RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon};
use radroots_events::kinds::{KIND_FARM, KIND_LIST_SET_GENERIC, KIND_PLOT, KIND_PROFILE};
use radroots_replica_db::{
farm, farm_gcs_location, farm_member, farm_member_claim, farm_tag, gcs_location, migrations,
diff --git a/crates/replica_sync/tests/ingest_roundtrip.rs b/crates/replica_sync/tests/ingest_roundtrip.rs
@@ -1,8 +1,6 @@
use radroots_events::RadrootsNostrEvent;
-use radroots_events::farm::{
- RadrootsFarm, RadrootsFarmLocation, RadrootsFarmRef, RadrootsGcsLocation, RadrootsGeoJsonPoint,
- RadrootsGeoJsonPolygon,
-};
+use radroots_events::farm::{RadrootsFarm, RadrootsFarmPublicLocation, RadrootsFarmRef};
+use radroots_events::gcs::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon};
use radroots_events::kinds::{
KIND_FARM, KIND_LIST_SET_FOLLOW, KIND_LIST_SET_GENERIC, KIND_PLOT, KIND_PROFILE,
};
@@ -224,11 +222,11 @@ fn seed_source(
};
let farm_row = unwrap_sql(farm::create(exec, &farm_fields), "farm").result;
- let point = radroots_events::farm::RadrootsGeoJsonPoint {
+ let point = radroots_events::gcs::RadrootsGeoJsonPoint {
r#type: "Point".to_string(),
coordinates: [-122.4, 37.7],
};
- let polygon = radroots_events::farm::RadrootsGeoJsonPolygon {
+ let polygon = radroots_events::gcs::RadrootsGeoJsonPolygon {
r#type: "Polygon".to_string(),
coordinates: vec![vec![
[-122.4, 37.7],
@@ -818,20 +816,18 @@ fn ingest_reports_query_fail_paths_for_profile_farm_plot_and_list_sets() {
12,
farm_d_tag,
"farm-query",
- Some(RadrootsFarmLocation {
- primary: Some("farm".to_string()),
+ Some(RadrootsFarmPublicLocation {
+ primary: "farm".to_string(),
city: None,
region: None,
country: None,
- gcs: Some(sample_gcs(37.7, -122.4, "9q8yy")),
+ geohash: "9q8yy".to_string(),
}),
Some(vec!["coffee".to_string()]),
);
assert_query_fail("select * from farm where", &farm_create);
assert_query_fail("insert into farm", &farm_create);
assert_query_fail("insert into farm_tag", &farm_create);
- assert_query_fail("insert into gcs_location", &farm_create);
- assert_query_fail("insert into farm_gcs_location", &farm_create);
assert_eq!(
radroots_replica_ingest_event(&exec, &farm_create).expect("seed farm"),
RadrootsReplicaIngestOutcome::Applied
@@ -1023,7 +1019,7 @@ fn farm_event(
created_at: u32,
d_tag: &str,
name: &str,
- location: Option<RadrootsFarmLocation>,
+ location: Option<RadrootsFarmPublicLocation>,
tags: Option<Vec<String>>,
) -> RadrootsNostrEvent {
let farm = RadrootsFarm {
@@ -1174,12 +1170,12 @@ fn ingest_event_paths_cover_profile_farm_plot_and_list_set_variants() {
let farm_pubkey = "e".repeat(64);
let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAA";
- let farm_location = RadrootsFarmLocation {
- primary: Some("farm-primary".to_string()),
+ let farm_location = RadrootsFarmPublicLocation {
+ primary: "farm-primary".to_string(),
city: Some("city".to_string()),
region: Some("region".to_string()),
country: Some("country".to_string()),
- gcs: Some(sample_gcs(37.7, -122.4, "9q8yy")),
+ geohash: "9q8yy".to_string(),
};
let farm_create = farm_event(
200,
diff --git a/crates/trade/src/dto.rs b/crates/trade/src/dto.rs
@@ -110,7 +110,7 @@ impl Dto for RadrootsTradeListing {
.with_field(field(
"location",
"location",
- ts_ref("RadrootsListingLocation"),
+ ts_ref("RadrootsListingPublicLocation"),
"crates/trade/src/listing/validation.rs",
38,
))
diff --git a/crates/trade/src/listing/codec.rs b/crates/trade/src/listing/codec.rs
@@ -13,8 +13,9 @@ use radroots_events::kinds::{KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA};
use radroots_events::listing::{
RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
RadrootsListingDeliveryMethod, RadrootsListingImage, RadrootsListingImageSize,
- RadrootsListingLocation, RadrootsListingProduct, RadrootsListingStatus,
+ RadrootsListingProduct, RadrootsListingPublicLocation, RadrootsListingStatus,
};
+use radroots_events::location::is_public_geohash5;
pub(crate) use radroots_events::order::RadrootsListingParseError as ListingParseError;
use radroots_events::plot::RadrootsPlotRef;
use radroots_events::resource_area::RadrootsResourceAreaRef;
@@ -33,6 +34,11 @@ const TAG_RADROOTS_PLOT: &str = "radroots:plot";
const TAG_LOCATION: &str = "location";
const TAG_IMAGE: &str = "image";
const TAG_GEOHASH: &str = "g";
+const TAG_LABEL: &str = "l";
+const TAG_LABEL_NS: &str = "L";
+const TAG_DD: &str = "dd";
+const TAG_DD_LAT: &str = "dd.lat";
+const TAG_DD_LON: &str = "dd.lon";
const TAG_INVENTORY: &str = "inventory";
const TAG_DELIVERY: &str = "delivery";
const TAG_RADROOTS_AVAILABILITY_START: &str = "radroots:availability_start";
@@ -41,6 +47,13 @@ const TAG_EXPIRES_AT: &str = "expires_at";
const TAG_P: &str = "p";
const TAG_A: &str = "a";
+struct ListingLocationDraft {
+ primary: String,
+ city: Option<String>,
+ region: Option<String>,
+ country: Option<String>,
+}
+
fn parse_decimal(s: &str, field: &str) -> Result<RadrootsCoreDecimal, ListingParseError> {
s.parse::<RadrootsCoreDecimal>()
.map_err(|_| ListingParseError::InvalidNumber(field.to_string()))
@@ -63,6 +76,40 @@ fn parse_u64_tag_value(value: Option<&String>, field: &str) -> Result<u64, Listi
.map_err(|_| ListingParseError::InvalidNumber(field.to_string()))
}
+fn reject_private_listing_location_content(content: &str) -> Result<(), ListingParseError> {
+ let trimmed = content.trim();
+ if !trimmed.starts_with('{') {
+ return Ok(());
+ }
+ #[cfg(feature = "serde_json")]
+ {
+ let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
+ return Ok(());
+ };
+ let Some(location) = value.get("location").and_then(|value| value.as_object()) else {
+ return Ok(());
+ };
+ for key in [
+ "lat",
+ "lng",
+ "lon",
+ "point",
+ "polygon",
+ "coordinates",
+ "accuracy",
+ "altitude",
+ "label",
+ "tag_0",
+ "gcs",
+ ] {
+ if location.contains_key(key) {
+ return Err(ListingParseError::InvalidJson("location".to_string()));
+ }
+ }
+ }
+ Ok(())
+}
+
fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, ListingParseError> {
let tag = tags
.iter()
@@ -85,6 +132,7 @@ pub fn listing_from_event_parts(
tags: &[Vec<String>],
content: &str,
) -> Result<RadrootsListing, ListingParseError> {
+ reject_private_listing_location_content(content)?;
let d_tag = parse_d_tag(tags)?;
let farm_ref = parse_farm_ref(tags)?;
let farm_pubkey = parse_farm_pubkey(tags)?;
@@ -95,6 +143,11 @@ pub fn listing_from_event_parts(
#[cfg(feature = "serde_json")]
{
if let Ok(mut listing) = serde_json::from_str::<RadrootsListing>(content) {
+ if let Some(location) = listing.location.as_ref()
+ && !is_public_geohash5(&location.geohash)
+ {
+ return Err(ListingParseError::InvalidTag(TAG_GEOHASH.to_string()));
+ }
if listing.d_tag != d_tag {
return Err(ListingParseError::InvalidTag(TAG_D.to_string()));
}
@@ -192,7 +245,7 @@ fn listing_from_tags(
let mut bin_drafts: Vec<BinDraft> = Vec::new();
let mut bin_order = 0usize;
let mut discounts: Vec<RadrootsCoreDiscount> = Vec::new();
- let mut location: Option<RadrootsListingLocation> = None;
+ let mut location: Option<ListingLocationDraft> = None;
let mut inventory_available: Option<RadrootsCoreDecimal> = None;
let mut availability_status: Option<RadrootsListingStatus> = None;
let mut availability_start: Option<u64> = None;
@@ -202,10 +255,6 @@ fn listing_from_tags(
let mut geohash: Option<String> = None;
let mut published_at: Option<u64> = None;
- let has_structured_location = tags
- .iter()
- .any(|tag| tag.first().map(|k| k.as_str()) == Some(TAG_LOCATION) && tag.len() >= 3);
-
for tag in tags {
if tag.is_empty() {
continue;
@@ -224,7 +273,7 @@ fn listing_from_tags(
"location" => {
let parse_structured_location = match tag.len() {
0 | 1 => false,
- 2 => !has_structured_location && location.is_none(),
+ 2 => false,
_ => true,
};
if parse_structured_location {
@@ -232,14 +281,11 @@ fn listing_from_tags(
if primary.trim().is_empty() {
return Err(ListingParseError::InvalidTag(TAG_LOCATION.to_string()));
}
- let mut loc = RadrootsListingLocation {
+ let mut loc = ListingLocationDraft {
primary: primary.to_string(),
city: None,
region: None,
country: None,
- lat: None,
- lng: None,
- geohash: None,
};
if let Some(city) = tag.get(2).and_then(|v| clean_value(v)) {
loc.city = Some(city);
@@ -368,9 +414,21 @@ fn listing_from_tags(
discounts.push(discount);
}
TAG_GEOHASH => {
- if let Some(value) = tag.get(1).and_then(|v| clean_value(v)) {
- geohash = Some(value);
+ let value = tag
+ .get(1)
+ .and_then(|v| clean_value(v))
+ .ok_or_else(|| ListingParseError::InvalidTag(TAG_GEOHASH.to_string()))?;
+ if !is_public_geohash5(&value) {
+ return Err(ListingParseError::InvalidTag(TAG_GEOHASH.to_string()));
}
+ let value = value.to_ascii_lowercase();
+ if geohash.as_ref().is_some_and(|existing| existing != &value) {
+ return Err(ListingParseError::InvalidTag(TAG_GEOHASH.to_string()));
+ }
+ geohash = Some(value);
+ }
+ TAG_DD | TAG_DD_LAT | TAG_DD_LON | TAG_LABEL | TAG_LABEL_NS => {
+ return Err(ListingParseError::InvalidTag(key.to_string()));
}
TAG_INVENTORY => {
let value = tag
@@ -439,10 +497,19 @@ fn listing_from_tags(
},
};
- let location = location.map(|mut loc| {
- loc.geohash = loc.geohash.or(geohash);
- loc
- });
+ let location = location
+ .map(|loc| {
+ let geohash =
+ geohash.ok_or_else(|| ListingParseError::InvalidTag(TAG_GEOHASH.to_string()))?;
+ Ok(RadrootsListingPublicLocation {
+ primary: loc.primary,
+ city: loc.city,
+ region: loc.region,
+ country: loc.country,
+ geohash,
+ })
+ })
+ .transpose()?;
if farm_pubkey != farm_ref.pubkey {
return Err(ListingParseError::InvalidTag(TAG_P.to_string()));
@@ -1100,7 +1167,7 @@ mod tests {
"Region".into(),
"SE".into(),
]);
- tags.push(vec![TAG_GEOHASH.into(), "u6se".into()]);
+ tags.push(vec![TAG_GEOHASH.into(), "u6sep".into()]);
tags.push(vec![TAG_INVENTORY.into(), "8".into()]);
tags.push(vec![TAG_RADROOTS_AVAILABILITY_START.into(), "10".into()]);
tags.push(vec![TAG_EXPIRES_AT.into(), "20".into()]);
@@ -1145,8 +1212,8 @@ mod tests {
"Some(Other { method: \"drone\" })"
);
assert_eq!(
- listing.location.as_ref().unwrap().geohash.as_deref(),
- Some("u6se")
+ listing.location.as_ref().unwrap().geohash,
+ "u6sep".to_string()
);
assert_eq!(listing.product.process.as_deref(), Some("washed"));
assert_eq!(listing.product.lot.as_deref(), Some("lot-7"));
@@ -1173,7 +1240,7 @@ mod tests {
)
.expect("listing");
- assert_eq!(listing.product.location.as_deref(), Some("fallback"));
+ assert_eq!(listing.product.location.as_deref(), Some("Farm"));
assert_eq!(
format!("{:?}", listing.delivery_method),
"Some(Other { method: \"parcel\" })"
@@ -1242,6 +1309,7 @@ mod tests {
"Town".into(),
"Region".into(),
]);
+ tags.push(vec![TAG_GEOHASH.into(), "u6sep".into()]);
tags.push(vec![TAG_LOCATION.into()]);
tags.push(vec![TAG_LOCATION.into(), "fallback".into()]);
let listing = listing_from_tags(
@@ -2361,7 +2429,6 @@ mod tests {
"1".into(),
"g".into(),
],
- vec![TAG_GEOHASH.into()],
];
let listing = listing_from_tags(
&tags,
diff --git a/crates/trade/src/listing/mutation.rs b/crates/trade/src/listing/mutation.rs
@@ -157,7 +157,7 @@ mod tests {
kinds::{KIND_LISTING, KIND_LISTING_DRAFT},
listing::{
RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
- RadrootsListingDeliveryMethod, RadrootsListingLocation, RadrootsListingProduct,
+ RadrootsListingDeliveryMethod, RadrootsListingProduct, RadrootsListingPublicLocation,
RadrootsListingStatus,
},
resource_area::RadrootsResourceAreaRef,
@@ -231,14 +231,12 @@ mod tests {
status: RadrootsListingStatus::Active,
}),
delivery_method: Some(RadrootsListingDeliveryMethod::Pickup),
- location: Some(RadrootsListingLocation {
+ location: Some(RadrootsListingPublicLocation {
primary: "Farm".to_string(),
- city: None,
- region: None,
- country: None,
- lat: None,
- lng: None,
- geohash: None,
+ city: Some("Town".to_string()),
+ region: Some("Region".to_string()),
+ country: Some("US".to_string()),
+ geohash: "9q8yy".to_string(),
}),
images: None,
}
diff --git a/crates/trade/src/listing/validation.rs b/crates/trade/src/listing/validation.rs
@@ -12,8 +12,9 @@ use radroots_events::{
kinds::is_listing_kind,
listing::{
RadrootsListing, RadrootsListingAvailability, RadrootsListingDeliveryMethod,
- RadrootsListingLocation,
+ RadrootsListingPublicLocation,
},
+ location::{has_textual_locality, is_public_geohash5},
order::RadrootsListingParseError,
trade_validation::RadrootsTradeValidationListingError as TradeListingValidationError,
};
@@ -35,7 +36,7 @@ pub struct RadrootsTradeListing {
pub unit_price: RadrootsCoreMoney,
pub inventory_available: RadrootsCoreDecimal,
pub availability: RadrootsListingAvailability,
- pub location: RadrootsListingLocation,
+ pub location: RadrootsListingPublicLocation,
pub delivery_method: RadrootsListingDeliveryMethod,
pub listing: RadrootsListing,
}
@@ -135,6 +136,20 @@ pub fn validate_listing_event(
.location
.clone()
.ok_or(TradeListingValidationError::MissingLocation)?;
+ if !has_textual_locality(
+ &location.primary,
+ location.city.as_deref(),
+ location.region.as_deref(),
+ location.country.as_deref(),
+ ) {
+ return Err(TradeListingValidationError::MissingLocationLocality);
+ }
+ if location.geohash.trim().is_empty() {
+ return Err(TradeListingValidationError::MissingLocationGeohash);
+ }
+ if !is_public_geohash5(&location.geohash) {
+ return Err(TradeListingValidationError::InvalidLocationGeohash);
+ }
let delivery_method = listing
.delivery_method
.clone()
@@ -173,7 +188,7 @@ mod tests {
kinds::{KIND_LISTING, KIND_LISTING_DRAFT},
listing::{
RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
- RadrootsListingDeliveryMethod, RadrootsListingLocation, RadrootsListingProduct,
+ RadrootsListingDeliveryMethod, RadrootsListingProduct, RadrootsListingPublicLocation,
},
};
@@ -238,14 +253,12 @@ mod tests {
status: radroots_events::listing::RadrootsListingStatus::Active,
}),
delivery_method: Some(RadrootsListingDeliveryMethod::Pickup),
- location: Some(RadrootsListingLocation {
+ location: Some(RadrootsListingPublicLocation {
primary: "Farm".into(),
- city: None,
- region: None,
- country: None,
- lat: None,
- lng: None,
- geohash: None,
+ city: Some("Town".into()),
+ region: Some("Region".into()),
+ country: Some("US".into()),
+ geohash: "9q8yy".into(),
}),
images: None,
}
@@ -491,6 +504,43 @@ mod tests {
}
#[test]
+ fn validate_listing_rejects_missing_location_locality() {
+ let mut listing = base_listing();
+ let location = listing.location.as_mut().expect("location");
+ location.city = None;
+ location.region = None;
+ location.country = None;
+ assert_validation_err(
+ listing,
+ TradeListingValidationError::MissingLocationLocality,
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_missing_location_geohash() {
+ let mut listing = base_listing();
+ listing.location.as_mut().expect("location").geohash = " ".into();
+ assert_validation_err(
+ listing,
+ TradeListingValidationError::ParseError {
+ error: crate::listing::codec::ListingParseError::InvalidTag("g".to_string()),
+ },
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_invalid_location_geohash() {
+ let mut listing = base_listing();
+ listing.location.as_mut().expect("location").geohash = "9q8yyz".into();
+ assert_validation_err(
+ listing,
+ TradeListingValidationError::ParseError {
+ error: crate::listing::codec::ListingParseError::InvalidTag("g".to_string()),
+ },
+ );
+ }
+
+ #[test]
fn validate_listing_rejects_missing_delivery_method() {
let mut listing = base_listing();
listing.delivery_method = None;
@@ -526,6 +576,9 @@ mod tests {
TradeListingValidationError::InvalidInventory,
TradeListingValidationError::MissingAvailability,
TradeListingValidationError::MissingLocation,
+ TradeListingValidationError::MissingLocationLocality,
+ TradeListingValidationError::MissingLocationGeohash,
+ TradeListingValidationError::InvalidLocationGeohash,
TradeListingValidationError::MissingDeliveryMethod,
];
for error in errors {