decode.rs (33230B)
1 #[cfg(not(feature = "std"))] 2 use alloc::{ 3 string::{String, ToString}, 4 vec::Vec, 5 }; 6 7 use radroots_core::pricing::Discount; 8 use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit}; 9 use radroots_event::{ 10 envelope::EventEnvelope, 11 envelope::kind::{KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA, is_classified_listing_kind}, 12 farm::FarmRef, 13 farm::location::is_public_geohash5, 14 farm::plot::PlotRef, 15 farm::resource_area::ResourceAreaRef, 16 id::{DTag, InventoryBinId}, 17 listing::operational::OperationalListingParseError, 18 listing::operational::{ 19 OperationalListing, OperationalListingAvailability, OperationalListingBin, 20 OperationalListingDeliveryMethod, OperationalListingImage, OperationalListingImageSize, 21 OperationalListingProduct, OperationalListingPublicLocation, OperationalListingStatus, 22 }, 23 tag::name::{TAG_D, TAG_PUBLISHED_AT}, 24 }; 25 26 use crate::d_tag::validate_d_tag_tag; 27 use crate::error::EventParseError; 28 use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent}; 29 30 const EXPECTED_CLASSIFIED_LISTING_KINDS: &str = "30402"; 31 const TAG_A: &str = "a"; 32 const TAG_P: &str = "p"; 33 const TAG_PRICE: &str = "price"; 34 const TAG_RADROOTS_BIN: &str = "radroots:bin"; 35 const TAG_RADROOTS_PRICE: &str = "radroots:price"; 36 const TAG_RADROOTS_DISCOUNT: &str = "radroots:discount"; 37 const TAG_RADROOTS_PRIMARY_BIN: &str = "radroots:primary_bin"; 38 const TAG_RADROOTS_RESOURCE_AREA: &str = "radroots:resource_area"; 39 const TAG_RADROOTS_PLOT: &str = "radroots:plot"; 40 const TAG_LOCATION: &str = "location"; 41 const TAG_IMAGE: &str = "image"; 42 const TAG_GEOHASH: &str = "g"; 43 const TAG_LABEL: &str = "l"; 44 const TAG_LABEL_NS: &str = "L"; 45 const TAG_DD: &str = "dd"; 46 const TAG_DD_LAT: &str = "dd.lat"; 47 const TAG_DD_LON: &str = "dd.lon"; 48 const TAG_INVENTORY: &str = "inventory"; 49 const TAG_DELIVERY: &str = "delivery"; 50 const TAG_RADROOTS_AVAILABILITY_START: &str = "radroots:availability_start"; 51 const TAG_STATUS: &str = "status"; 52 const TAG_EXPIRES_AT: &str = "expires_at"; 53 54 #[derive(Clone, Debug, PartialEq, Eq)] 55 enum OperationalListingDecodeError { 56 MissingTag(&'static str), 57 InvalidTag(&'static str), 58 InvalidNumber(&'static str), 59 InvalidUnit(&'static str), 60 InvalidCurrency(&'static str), 61 InvalidJson(&'static str), 62 #[cfg(feature = "json")] 63 InvalidDiscount(&'static str), 64 } 65 66 impl OperationalListingDecodeError { 67 fn into_listing_parse_error(self) -> OperationalListingParseError { 68 match self { 69 Self::MissingTag(field) => OperationalListingParseError::MissingTag(field.to_string()), 70 Self::InvalidTag(field) => OperationalListingParseError::InvalidTag(field.to_string()), 71 Self::InvalidNumber(field) => { 72 OperationalListingParseError::InvalidNumber(field.to_string()) 73 } 74 Self::InvalidUnit(_) => OperationalListingParseError::InvalidUnit, 75 Self::InvalidCurrency(_) => OperationalListingParseError::InvalidCurrency, 76 Self::InvalidJson(field) => { 77 OperationalListingParseError::InvalidJson(field.to_string()) 78 } 79 #[cfg(feature = "json")] 80 Self::InvalidDiscount(field) => { 81 OperationalListingParseError::InvalidDiscount(field.to_string()) 82 } 83 } 84 } 85 86 fn into_event_parse_error(self) -> EventParseError { 87 match self { 88 Self::MissingTag(field) => EventParseError::MissingTag(field), 89 Self::InvalidTag(field) 90 | Self::InvalidNumber(field) 91 | Self::InvalidUnit(field) 92 | Self::InvalidCurrency(field) => EventParseError::InvalidTag(field), 93 #[cfg(feature = "json")] 94 Self::InvalidDiscount(field) => EventParseError::InvalidTag(field), 95 Self::InvalidJson(_) => EventParseError::InvalidJson("content"), 96 } 97 } 98 } 99 100 struct OperationalListingLocationDraft { 101 primary: String, 102 city: Option<String>, 103 region: Option<String>, 104 country: Option<String>, 105 } 106 107 fn parse_decimal( 108 value: &str, 109 field: &'static str, 110 ) -> Result<Decimal, OperationalListingDecodeError> { 111 value 112 .parse::<Decimal>() 113 .map_err(|_| OperationalListingDecodeError::InvalidNumber(field)) 114 } 115 116 fn reject_private_listing_location_content( 117 content: &str, 118 ) -> Result<(), OperationalListingDecodeError> { 119 let trimmed = content.trim(); 120 if !trimmed.starts_with('{') { 121 return Ok(()); 122 } 123 #[cfg(feature = "json")] 124 { 125 let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else { 126 return Ok(()); 127 }; 128 let Some(location) = value.get("location").and_then(|value| value.as_object()) else { 129 return Ok(()); 130 }; 131 for key in [ 132 "lat", 133 "lng", 134 "lon", 135 "point", 136 "polygon", 137 "coordinates", 138 "accuracy", 139 "altitude", 140 "label", 141 "tag_0", 142 "gcs", 143 ] { 144 if location.contains_key(key) { 145 return Err(OperationalListingDecodeError::InvalidJson("location")); 146 } 147 } 148 } 149 Ok(()) 150 } 151 152 fn parse_currency( 153 value: &str, 154 field: &'static str, 155 ) -> Result<Currency, OperationalListingDecodeError> { 156 let upper = value.trim().to_ascii_uppercase(); 157 Currency::from_str_upper(&upper) 158 .map_err(|_| OperationalListingDecodeError::InvalidCurrency(field)) 159 } 160 161 fn parse_unit(value: &str, field: &'static str) -> Result<Unit, OperationalListingDecodeError> { 162 value 163 .parse::<Unit>() 164 .map_err(|_| OperationalListingDecodeError::InvalidUnit(field)) 165 } 166 167 fn parse_u64_tag_value( 168 value: Option<&String>, 169 field: &'static str, 170 ) -> Result<u64, OperationalListingDecodeError> { 171 value 172 .ok_or(OperationalListingDecodeError::InvalidTag(field))? 173 .parse::<u64>() 174 .map_err(|_| OperationalListingDecodeError::InvalidNumber(field)) 175 } 176 177 fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, OperationalListingDecodeError> { 178 let tag = tags 179 .iter() 180 .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_D)) 181 .ok_or(OperationalListingDecodeError::MissingTag(TAG_D))?; 182 let value = tag 183 .get(1) 184 .map(|value| value.to_string()) 185 .ok_or(OperationalListingDecodeError::InvalidTag(TAG_D))?; 186 if value.trim().is_empty() { 187 return Err(OperationalListingDecodeError::InvalidTag(TAG_D)); 188 } 189 validate_d_tag_tag(&value, TAG_D) 190 .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_D))?; 191 Ok(value) 192 } 193 194 fn parse_farm_ref(tags: &[Vec<String>]) -> Result<FarmRef, OperationalListingDecodeError> { 195 for tag in tags 196 .iter() 197 .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_A)) 198 { 199 let value = tag 200 .get(1) 201 .map(|value| value.to_string()) 202 .ok_or(OperationalListingDecodeError::InvalidTag(TAG_A))?; 203 let mut parts = value.splitn(3, ':'); 204 let kind = parts 205 .next() 206 .and_then(|raw| raw.parse::<u32>().ok()) 207 .ok_or(OperationalListingDecodeError::InvalidTag(TAG_A))?; 208 if kind != KIND_FARM { 209 continue; 210 } 211 let pubkey = parts 212 .next() 213 .ok_or(OperationalListingDecodeError::InvalidTag(TAG_A))? 214 .to_string(); 215 let d_tag = parts 216 .next() 217 .ok_or(OperationalListingDecodeError::InvalidTag(TAG_A))? 218 .to_string(); 219 if pubkey.trim().is_empty() || d_tag.trim().is_empty() { 220 return Err(OperationalListingDecodeError::InvalidTag(TAG_A)); 221 } 222 validate_d_tag_tag(&d_tag, TAG_A) 223 .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_A))?; 224 return Ok(FarmRef { pubkey, d_tag }); 225 } 226 Err(OperationalListingDecodeError::MissingTag(TAG_A)) 227 } 228 229 fn parse_farm_pubkey(tags: &[Vec<String>]) -> Result<String, OperationalListingDecodeError> { 230 let tag = tags 231 .iter() 232 .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_P)) 233 .ok_or(OperationalListingDecodeError::MissingTag(TAG_P))?; 234 let value = tag 235 .get(1) 236 .map(|value| value.to_string()) 237 .ok_or(OperationalListingDecodeError::InvalidTag(TAG_P))?; 238 if value.trim().is_empty() { 239 return Err(OperationalListingDecodeError::InvalidTag(TAG_P)); 240 } 241 Ok(value) 242 } 243 244 fn parse_resource_area( 245 tags: &[Vec<String>], 246 ) -> Result<Option<ResourceAreaRef>, OperationalListingDecodeError> { 247 let tag = tags 248 .iter() 249 .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_RADROOTS_RESOURCE_AREA)); 250 let Some(tag) = tag else { 251 return Ok(None); 252 }; 253 let value = tag.get(1).map(|value| value.to_string()).ok_or( 254 OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA), 255 )?; 256 let mut parts = value.splitn(3, ':'); 257 let kind = parts.next().and_then(|raw| raw.parse::<u32>().ok()).ok_or( 258 OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA), 259 )?; 260 if kind != KIND_RESOURCE_AREA { 261 return Err(OperationalListingDecodeError::InvalidTag( 262 TAG_RADROOTS_RESOURCE_AREA, 263 )); 264 } 265 let pubkey = parts 266 .next() 267 .ok_or(OperationalListingDecodeError::InvalidTag( 268 TAG_RADROOTS_RESOURCE_AREA, 269 ))? 270 .to_string(); 271 let d_tag = parts 272 .next() 273 .ok_or(OperationalListingDecodeError::InvalidTag( 274 TAG_RADROOTS_RESOURCE_AREA, 275 ))? 276 .to_string(); 277 if pubkey.trim().is_empty() || d_tag.trim().is_empty() { 278 return Err(OperationalListingDecodeError::InvalidTag( 279 TAG_RADROOTS_RESOURCE_AREA, 280 )); 281 } 282 validate_d_tag_tag(&d_tag, TAG_RADROOTS_RESOURCE_AREA) 283 .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?; 284 Ok(Some(ResourceAreaRef { pubkey, d_tag })) 285 } 286 287 fn parse_plot_ref(tags: &[Vec<String>]) -> Result<Option<PlotRef>, OperationalListingDecodeError> { 288 let tag = tags 289 .iter() 290 .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_RADROOTS_PLOT)); 291 let Some(tag) = tag else { 292 return Ok(None); 293 }; 294 let value = tag 295 .get(1) 296 .map(|value| value.to_string()) 297 .ok_or(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?; 298 let mut parts = value.splitn(3, ':'); 299 let kind = parts 300 .next() 301 .and_then(|raw| raw.parse::<u32>().ok()) 302 .ok_or(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?; 303 if kind != KIND_PLOT { 304 return Err(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT)); 305 } 306 let pubkey = parts 307 .next() 308 .ok_or(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))? 309 .to_string(); 310 let d_tag = parts 311 .next() 312 .ok_or(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))? 313 .to_string(); 314 if pubkey.trim().is_empty() || d_tag.trim().is_empty() { 315 return Err(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT)); 316 } 317 validate_d_tag_tag(&d_tag, TAG_RADROOTS_PLOT) 318 .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?; 319 Ok(Some(PlotRef { pubkey, d_tag })) 320 } 321 322 pub fn operational_listing_from_event( 323 kind: u32, 324 tags: &[Vec<String>], 325 content: &str, 326 ) -> Result<OperationalListing, EventParseError> { 327 if !is_classified_listing_kind(kind) { 328 return Err(EventParseError::InvalidKind { 329 expected: EXPECTED_CLASSIFIED_LISTING_KINDS, 330 got: kind, 331 }); 332 } 333 decode_operational_listing_from_event_parts(tags, content) 334 .map_err(OperationalListingDecodeError::into_event_parse_error) 335 } 336 337 pub fn operational_listing_from_event_parts( 338 tags: &[Vec<String>], 339 content: &str, 340 ) -> Result<OperationalListing, OperationalListingParseError> { 341 decode_operational_listing_from_event_parts(tags, content) 342 .map_err(OperationalListingDecodeError::into_listing_parse_error) 343 } 344 345 pub fn operational_listing_from_nostr_event( 346 event: &EventEnvelope, 347 ) -> Result<OperationalListing, OperationalListingParseError> { 348 if !is_classified_listing_kind(event.kind_u32()) { 349 return Err(OperationalListingParseError::InvalidKind(event.kind_u32())); 350 } 351 operational_listing_from_event_parts(&event.tags_as_vec(), event.content()) 352 } 353 354 fn decode_operational_listing_from_event_parts( 355 tags: &[Vec<String>], 356 content: &str, 357 ) -> Result<OperationalListing, OperationalListingDecodeError> { 358 reject_private_listing_location_content(content)?; 359 let d_tag = parse_d_tag(tags)?; 360 let farm_ref = parse_farm_ref(tags)?; 361 let farm_pubkey = parse_farm_pubkey(tags)?; 362 let resource_area = parse_resource_area(tags)?; 363 let plot = parse_plot_ref(tags)?; 364 365 let mut product = OperationalListingProduct { 366 key: String::new(), 367 title: String::new(), 368 category: String::new(), 369 summary: None, 370 process: None, 371 lot: None, 372 location: None, 373 profile: None, 374 year: None, 375 }; 376 let mut primary_bin_id: Option<String> = None; 377 let mut bin_drafts: Vec<BinDraft> = Vec::new(); 378 let mut bin_order = 0usize; 379 let mut discounts: Vec<Discount> = Vec::new(); 380 let mut location: Option<OperationalListingLocationDraft> = None; 381 let mut inventory_available: Option<Decimal> = None; 382 let mut availability_status: Option<OperationalListingStatus> = None; 383 let mut availability_start: Option<u64> = None; 384 let mut availability_end: Option<u64> = None; 385 let mut delivery_method: Option<OperationalListingDeliveryMethod> = None; 386 let mut images: Vec<OperationalListingImage> = Vec::new(); 387 let mut geohash: Option<String> = None; 388 let mut published_at: Option<u64> = None; 389 390 for tag in tags { 391 if tag.is_empty() { 392 continue; 393 } 394 match tag[0].as_str() { 395 "key" => set_if_empty(&mut product.key, tag.get(1)), 396 "title" => set_if_empty(&mut product.title, tag.get(1)), 397 "category" => set_if_empty(&mut product.category, tag.get(1)), 398 "summary" => set_optional(&mut product.summary, tag.get(1)), 399 TAG_PUBLISHED_AT => { 400 published_at = Some(parse_u64_tag_value(tag.get(1), TAG_PUBLISHED_AT)?); 401 } 402 "process" => set_optional(&mut product.process, tag.get(1)), 403 "lot" => set_optional(&mut product.lot, tag.get(1)), 404 "location" => { 405 let parse_structured_location = !matches!(tag.len(), 0..=2); 406 if parse_structured_location { 407 let primary = tag 408 .get(1) 409 .and_then(|value| clean_value(value)) 410 .ok_or(OperationalListingDecodeError::InvalidTag(TAG_LOCATION))?; 411 let mut parsed = OperationalListingLocationDraft { 412 primary, 413 city: None, 414 region: None, 415 country: None, 416 }; 417 if let Some(city) = tag.get(2).and_then(|value| clean_value(value)) { 418 parsed.city = Some(city); 419 } 420 if let Some(region) = tag.get(3).and_then(|value| clean_value(value)) { 421 parsed.region = Some(region); 422 } 423 if let Some(country) = tag.get(4).and_then(|value| clean_value(value)) { 424 parsed.country = Some(country); 425 } 426 location = Some(parsed); 427 } else { 428 set_optional(&mut product.location, tag.get(1)); 429 } 430 } 431 "profile" => set_optional(&mut product.profile, tag.get(1)), 432 "year" => set_optional(&mut product.year, tag.get(1)), 433 TAG_PRICE => {} 434 TAG_RADROOTS_PRIMARY_BIN => { 435 let value = tag.get(1).and_then(|value| clean_value(value)).ok_or( 436 OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN), 437 )?; 438 if let Some(existing) = primary_bin_id.as_ref() { 439 if existing != &value { 440 return Err(OperationalListingDecodeError::InvalidTag( 441 TAG_RADROOTS_PRIMARY_BIN, 442 )); 443 } 444 } else { 445 primary_bin_id = Some(value); 446 } 447 } 448 TAG_RADROOTS_BIN => { 449 if tag.len() < 4 || tag.len() > 7 { 450 return Err(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_BIN)); 451 } 452 let bin_id = tag 453 .get(1) 454 .and_then(|value| clean_value(value)) 455 .ok_or(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_BIN))?; 456 let amount = parse_decimal(&tag[2], TAG_RADROOTS_BIN)?; 457 let unit = parse_unit(&tag[3], TAG_RADROOTS_BIN)?; 458 if unit != unit.canonical_unit() { 459 return Err(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_BIN)); 460 } 461 let bin = upsert_bin(&mut bin_drafts, &bin_id, &mut bin_order); 462 if bin.quantity.is_some() { 463 return Err(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_BIN)); 464 } 465 bin.quantity = 466 Some(Quantity::try_new(amount, unit).map_err(|_| { 467 OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_BIN) 468 })?); 469 470 match tag.as_slice() { 471 [_, _, _, _, display_amount_raw, display_unit_raw] 472 | [_, _, _, _, display_amount_raw, display_unit_raw, _] => { 473 let display_amount = parse_decimal(display_amount_raw, TAG_RADROOTS_BIN)?; 474 let display_unit = parse_unit(display_unit_raw, TAG_RADROOTS_BIN)?; 475 bin.display_amount = Some(display_amount); 476 bin.display_unit = Some(display_unit); 477 if let [_, _, _, _, _, _, label] = tag.as_slice() { 478 bin.display_label = clean_value(label); 479 } 480 } 481 [_, _, _, _, _] => { 482 return Err(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_BIN)); 483 } 484 _ => {} 485 } 486 } 487 TAG_RADROOTS_PRICE => { 488 if tag.len() < 6 || tag.len() > 8 { 489 return Err(OperationalListingDecodeError::InvalidTag( 490 TAG_RADROOTS_PRICE, 491 )); 492 } 493 let bin_id = tag.get(1).and_then(|value| clean_value(value)).ok_or( 494 OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE), 495 )?; 496 let amount = parse_decimal(&tag[2], TAG_RADROOTS_PRICE)?; 497 let currency = parse_currency(&tag[3], TAG_RADROOTS_PRICE)?; 498 let per_amount = parse_decimal(&tag[4], TAG_RADROOTS_PRICE)?; 499 let per_unit = parse_unit(&tag[5], TAG_RADROOTS_PRICE)?; 500 let money = Money::try_new(amount, currency) 501 .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE))?; 502 let quantity = Quantity::try_new(per_amount, per_unit) 503 .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE))?; 504 let price_per_canonical_unit = QuantityPrice::try_new(money, quantity) 505 .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE))?; 506 if !price_per_canonical_unit.is_price_per_canonical_unit() { 507 return Err(OperationalListingDecodeError::InvalidTag( 508 TAG_RADROOTS_PRICE, 509 )); 510 } 511 let bin = upsert_bin(&mut bin_drafts, &bin_id, &mut bin_order); 512 if bin.price_per_canonical_unit.is_some() { 513 return Err(OperationalListingDecodeError::InvalidTag( 514 TAG_RADROOTS_PRICE, 515 )); 516 } 517 bin.price_per_canonical_unit = Some(price_per_canonical_unit); 518 519 match tag.as_slice() { 520 [_, _, _, _, _, _, _] => { 521 return Err(OperationalListingDecodeError::InvalidTag( 522 TAG_RADROOTS_PRICE, 523 )); 524 } 525 [_, _, _, _, _, _, display_price_raw, display_unit_raw] => { 526 let display_price = parse_decimal(display_price_raw, TAG_RADROOTS_PRICE)?; 527 let display_unit = parse_unit(display_unit_raw, TAG_RADROOTS_PRICE)?; 528 bin.display_price = 529 Some(Money::try_new(display_price, currency).map_err(|_| { 530 OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE) 531 })?); 532 bin.display_price_unit = Some(display_unit); 533 } 534 _ => {} 535 } 536 } 537 TAG_RADROOTS_DISCOUNT => { 538 let payload = tag.get(1).ok_or(OperationalListingDecodeError::InvalidTag( 539 TAG_RADROOTS_DISCOUNT, 540 ))?; 541 discounts.push(parse_discount(payload)?); 542 } 543 TAG_GEOHASH => { 544 let value = tag 545 .get(1) 546 .and_then(|value| clean_value(value)) 547 .ok_or(OperationalListingDecodeError::InvalidTag(TAG_GEOHASH))?; 548 if !is_public_geohash5(&value) { 549 return Err(OperationalListingDecodeError::InvalidTag(TAG_GEOHASH)); 550 } 551 let value = value.to_ascii_lowercase(); 552 if geohash.as_ref().is_some_and(|existing| existing != &value) { 553 return Err(OperationalListingDecodeError::InvalidTag(TAG_GEOHASH)); 554 } 555 geohash = Some(value); 556 } 557 TAG_DD => return Err(OperationalListingDecodeError::InvalidTag(TAG_DD)), 558 TAG_DD_LAT => return Err(OperationalListingDecodeError::InvalidTag(TAG_DD_LAT)), 559 TAG_DD_LON => return Err(OperationalListingDecodeError::InvalidTag(TAG_DD_LON)), 560 TAG_LABEL => return Err(OperationalListingDecodeError::InvalidTag(TAG_LABEL)), 561 TAG_LABEL_NS => return Err(OperationalListingDecodeError::InvalidTag(TAG_LABEL_NS)), 562 TAG_INVENTORY => { 563 let value = tag 564 .get(1) 565 .ok_or(OperationalListingDecodeError::InvalidTag(TAG_INVENTORY))?; 566 inventory_available = Some(parse_decimal(value, TAG_INVENTORY)?); 567 } 568 TAG_RADROOTS_AVAILABILITY_START => { 569 availability_start = Some(parse_u64_tag_value( 570 tag.get(1), 571 TAG_RADROOTS_AVAILABILITY_START, 572 )?); 573 } 574 TAG_EXPIRES_AT => { 575 availability_end = Some(parse_u64_tag_value(tag.get(1), TAG_EXPIRES_AT)?); 576 } 577 TAG_STATUS => { 578 let status = tag 579 .get(1) 580 .and_then(|value| clean_value(value)) 581 .unwrap_or_default(); 582 availability_status = Some(parse_status(&status)); 583 } 584 TAG_DELIVERY => { 585 let method = tag 586 .get(1) 587 .and_then(|value| clean_value(value)) 588 .unwrap_or_default(); 589 delivery_method = Some(match method.as_str() { 590 "pickup" => OperationalListingDeliveryMethod::Pickup, 591 "local_delivery" => OperationalListingDeliveryMethod::LocalDelivery, 592 "shipping" => OperationalListingDeliveryMethod::Shipping, 593 "other" => OperationalListingDeliveryMethod::Other { 594 method: tag 595 .get(2) 596 .and_then(|value| clean_value(value)) 597 .unwrap_or_default(), 598 }, 599 other => OperationalListingDeliveryMethod::Other { 600 method: other.to_string(), 601 }, 602 }); 603 } 604 TAG_IMAGE => { 605 let url = tag 606 .get(1) 607 .ok_or(OperationalListingDecodeError::InvalidTag(TAG_IMAGE))?; 608 if url.trim().is_empty() { 609 continue; 610 } 611 images.push(OperationalListingImage { 612 url: url.to_string(), 613 size: tag.get(2).and_then(|value| parse_image_size(value)), 614 }); 615 } 616 _ => {} 617 } 618 } 619 620 let availability = match availability_status { 621 Some(status) => Some(OperationalListingAvailability::Status { status }), 622 None => match (availability_start, availability_end) { 623 (None, None) => None, 624 (start, end) => Some(OperationalListingAvailability::Window { start, end }), 625 }, 626 }; 627 628 let location = location 629 .map(|location| { 630 let geohash = geohash.ok_or(OperationalListingDecodeError::InvalidTag(TAG_GEOHASH))?; 631 Ok::<OperationalListingPublicLocation, OperationalListingDecodeError>( 632 OperationalListingPublicLocation { 633 primary: location.primary, 634 city: location.city, 635 region: location.region, 636 country: location.country, 637 geohash, 638 }, 639 ) 640 }) 641 .transpose()?; 642 643 if farm_pubkey != farm_ref.pubkey { 644 return Err(OperationalListingDecodeError::InvalidTag(TAG_P)); 645 } 646 647 let primary_bin_id = primary_bin_id.ok_or(OperationalListingDecodeError::MissingTag( 648 TAG_RADROOTS_PRIMARY_BIN, 649 ))?; 650 let bins = build_bins(bin_drafts)?; 651 if !bins.iter().any(|bin| bin.bin_id.as_str() == primary_bin_id) { 652 return Err(OperationalListingDecodeError::InvalidTag( 653 TAG_RADROOTS_PRIMARY_BIN, 654 )); 655 } 656 657 let d_tag = 658 DTag::parse(&d_tag).map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_D))?; 659 let primary_bin_id = InventoryBinId::parse(&primary_bin_id) 660 .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN))?; 661 662 Ok(OperationalListing { 663 d_tag, 664 published_at, 665 farm: farm_ref, 666 product, 667 primary_bin_id, 668 bins, 669 resource_area, 670 plot, 671 discounts: if discounts.is_empty() { 672 None 673 } else { 674 Some(discounts) 675 }, 676 inventory_available, 677 availability, 678 delivery_method, 679 location, 680 images: if images.is_empty() { 681 None 682 } else { 683 Some(images) 684 }, 685 }) 686 } 687 688 pub fn data_from_event( 689 id: String, 690 author: String, 691 published_at: u64, 692 kind: u32, 693 content: String, 694 tags: Vec<Vec<String>>, 695 ) -> Result<RadrootsParsedData<OperationalListing>, EventParseError> { 696 let listing = operational_listing_from_event(kind, &tags, &content)?; 697 Ok(RadrootsParsedData::new( 698 id, 699 author, 700 published_at, 701 kind, 702 listing, 703 )) 704 } 705 706 pub fn parsed_from_event( 707 id: String, 708 author: String, 709 published_at: u64, 710 kind: u32, 711 content: String, 712 tags: Vec<Vec<String>>, 713 sig: String, 714 ) -> Result<RadrootsParsedEvent<OperationalListing>, EventParseError> { 715 let data = data_from_event( 716 id.clone(), 717 author.clone(), 718 published_at, 719 kind, 720 content.clone(), 721 tags.clone(), 722 )?; 723 RadrootsParsedEvent::from_parts( 724 id, 725 author, 726 published_at, 727 kind, 728 content, 729 tags, 730 sig, 731 data.data, 732 ) 733 } 734 735 pub fn data_from_nostr_event( 736 event: &EventEnvelope, 737 ) -> Result<RadrootsParsedData<OperationalListing>, EventParseError> { 738 data_from_event( 739 event.id_hex(), 740 event.author().to_hex().to_string(), 741 event.created_at_u64(), 742 event.kind_u32(), 743 event.content().to_string(), 744 event.tags_as_vec(), 745 ) 746 } 747 748 pub fn parsed_from_nostr_event( 749 event: &EventEnvelope, 750 ) -> Result<RadrootsParsedEvent<OperationalListing>, EventParseError> { 751 parsed_from_event( 752 event.id_hex(), 753 event.author().to_hex().to_string(), 754 event.created_at_u64(), 755 event.kind_u32(), 756 event.content().to_string(), 757 event.tags_as_vec(), 758 event.signature_hex(), 759 ) 760 } 761 762 fn clean_value(value: &str) -> Option<String> { 763 let trimmed = value.trim(); 764 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("null") { 765 None 766 } else { 767 Some(trimmed.to_string()) 768 } 769 } 770 771 fn set_if_empty(target: &mut String, value: Option<&String>) { 772 if target.trim().is_empty() 773 && let Some(value) = value.and_then(|value| clean_value(value)) 774 { 775 *target = value; 776 } 777 } 778 779 fn set_optional(target: &mut Option<String>, value: Option<&String>) { 780 if target.is_none() 781 && let Some(value) = value.and_then(|value| clean_value(value)) 782 { 783 *target = Some(value); 784 } 785 } 786 787 fn parse_status(value: &str) -> OperationalListingStatus { 788 match value.trim().to_ascii_lowercase().as_str() { 789 "active" => OperationalListingStatus::Active, 790 "sold" => OperationalListingStatus::Sold, 791 other => OperationalListingStatus::Other { 792 value: other.to_string(), 793 }, 794 } 795 } 796 797 fn parse_image_size(value: &str) -> Option<OperationalListingImageSize> { 798 let (w_raw, h_raw) = value.split_once('x')?; 799 let w = w_raw.parse::<u32>().ok()?; 800 let h = h_raw.parse::<u32>().ok()?; 801 Some(OperationalListingImageSize { w, h }) 802 } 803 804 fn parse_discount(payload: &str) -> Result<Discount, OperationalListingDecodeError> { 805 #[cfg(feature = "json")] 806 { 807 serde_json::from_str(payload) 808 .map_err(|_| OperationalListingDecodeError::InvalidDiscount(TAG_RADROOTS_DISCOUNT)) 809 } 810 #[cfg(not(feature = "json"))] 811 { 812 let _ = payload; 813 Err(OperationalListingDecodeError::InvalidJson("discount")) 814 } 815 } 816 817 #[derive(Clone, Debug)] 818 struct BinDraft { 819 bin_id: String, 820 order_index: usize, 821 quantity: Option<Quantity>, 822 display_amount: Option<Decimal>, 823 display_unit: Option<Unit>, 824 display_label: Option<String>, 825 price_per_canonical_unit: Option<QuantityPrice>, 826 display_price: Option<Money>, 827 display_price_unit: Option<Unit>, 828 } 829 830 fn upsert_bin<'a>( 831 bins: &'a mut Vec<BinDraft>, 832 bin_id: &str, 833 order_index: &mut usize, 834 ) -> &'a mut BinDraft { 835 if let Some(position) = bins.iter().position(|bin| bin.bin_id == bin_id) { 836 return &mut bins[position]; 837 } 838 bins.push(BinDraft { 839 bin_id: bin_id.to_string(), 840 order_index: *order_index, 841 quantity: None, 842 display_amount: None, 843 display_unit: None, 844 display_label: None, 845 price_per_canonical_unit: None, 846 display_price: None, 847 display_price_unit: None, 848 }); 849 *order_index += 1; 850 let index = bins.len() - 1; 851 &mut bins[index] 852 } 853 854 fn build_bins( 855 mut drafts: Vec<BinDraft>, 856 ) -> Result<Vec<OperationalListingBin>, OperationalListingDecodeError> { 857 drafts.sort_by_key(|draft| draft.order_index); 858 let mut bins = Vec::with_capacity(drafts.len()); 859 for draft in drafts { 860 let quantity = draft 861 .quantity 862 .ok_or(OperationalListingDecodeError::MissingTag(TAG_RADROOTS_BIN))?; 863 let price = 864 draft 865 .price_per_canonical_unit 866 .ok_or(OperationalListingDecodeError::MissingTag( 867 TAG_RADROOTS_PRICE, 868 ))?; 869 if quantity.unit() != price.quantity().unit() { 870 return Err(OperationalListingDecodeError::InvalidTag( 871 TAG_RADROOTS_PRICE, 872 )); 873 } 874 bins.push(OperationalListingBin { 875 bin_id: InventoryBinId::parse(&draft.bin_id) 876 .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_BIN))?, 877 quantity, 878 price_per_canonical_unit: price, 879 display_amount: draft.display_amount, 880 display_unit: draft.display_unit, 881 display_label: draft.display_label, 882 display_price: draft.display_price, 883 display_price_unit: draft.display_price_unit, 884 }); 885 } 886 Ok(bins) 887 }