commit 69cad08ce80797b7806f32048393a25be1817382
parent ce926c64b4bbd3ea0f8c278dff0de50f615920c3
Author: triesap <triesap@radroots.dev>
Date: Sat, 3 Jan 2026 22:58:45 +0000
events: add resource_area and resource_cap jsonrpc methods
- Register events.resource_area.* and events.resource_cap.* modules
- Implement list endpoints with author/time bounds filtering and relay fetch
- Implement publish endpoints with tag building and optional extra tags
- Add tests for created_at desc sorting and tag-d fallback decoding
Diffstat:
8 files changed, 561 insertions(+), 0 deletions(-)
diff --git a/src/api/jsonrpc/methods/events/mod.rs b/src/api/jsonrpc/methods/events/mod.rs
@@ -1,5 +1,7 @@
pub mod farm;
pub mod plot;
+pub mod resource_area;
+pub mod resource_cap;
pub mod listing;
pub mod post;
pub mod profile;
diff --git a/src/api/jsonrpc/methods/events/resource_area/list.rs b/src/api/jsonrpc/methods/events/resource_area/list.rs
@@ -0,0 +1,228 @@
+use anyhow::Result;
+use jsonrpsee::server::RpcModule;
+use serde::Serialize;
+use std::time::Duration;
+
+use crate::api::jsonrpc::nostr::{event_tags, event_view_with_tags, NostrEventView};
+use crate::api::jsonrpc::params::{
+ apply_time_bounds,
+ limit_or,
+ parse_pubkeys_opt,
+ timeout_or,
+ EventListParams,
+};
+use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError};
+use radroots_events::kinds::KIND_RESOURCE_AREA;
+use radroots_events::resource_area::RadrootsResourceArea;
+use radroots_events_codec::resource_area::decode::resource_area_from_event;
+use radroots_nostr::prelude::{
+ RadrootsNostrEvent,
+ RadrootsNostrFilter,
+ RadrootsNostrKind,
+};
+
+#[derive(Clone, Debug, Serialize)]
+struct ResourceAreaEventFlat {
+ #[serde(flatten)]
+ event: NostrEventView,
+ resource_area: Option<RadrootsResourceArea>,
+}
+
+#[derive(Clone, Debug, Serialize)]
+struct ResourceAreaListResponse {
+ resource_areas: Vec<ResourceAreaEventFlat>,
+}
+
+fn build_resource_area_rows<I>(events: I) -> Vec<ResourceAreaEventFlat>
+where
+ I: IntoIterator<Item = RadrootsNostrEvent>,
+{
+ let mut items = events
+ .into_iter()
+ .map(|ev| {
+ let tags = event_tags(&ev);
+ let kind = ev.kind.as_u16() as u32;
+ let resource_area = resource_area_from_event(kind, &tags, &ev.content).ok();
+ ResourceAreaEventFlat {
+ event: event_view_with_tags(&ev, tags),
+ resource_area,
+ }
+ })
+ .collect::<Vec<_>>();
+ items.sort_by(|a, b| b.event.created_at.cmp(&a.event.created_at));
+ items
+}
+
+pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> {
+ registry.track("events.resource_area.list");
+ m.register_async_method("events.resource_area.list", |params, ctx, _| async move {
+ if ctx.state.client.relays().await.is_empty() {
+ return Err(RpcError::NoRelays);
+ }
+
+ let EventListParams {
+ authors,
+ limit,
+ since,
+ until,
+ timeout_secs,
+ } = params
+ .parse::<Option<EventListParams>>()
+ .map_err(|e| RpcError::InvalidParams(e.to_string()))?
+ .unwrap_or_default();
+
+ let limit = limit_or(limit);
+
+ let mut filter = RadrootsNostrFilter::new()
+ .limit(limit)
+ .kind(RadrootsNostrKind::Custom(KIND_RESOURCE_AREA as u16));
+
+ if let Some(authors) = parse_pubkeys_opt("author", authors)? {
+ filter = filter.authors(authors);
+ } else {
+ filter = filter.author(ctx.state.pubkey);
+ }
+ filter = apply_time_bounds(filter, since, until);
+
+ let events = ctx
+ .state
+ .client
+ .fetch_events(filter, Duration::from_secs(timeout_or(timeout_secs)))
+ .await
+ .map_err(|e| RpcError::Other(format!("fetch failed: {e}")))?;
+
+ let items = build_resource_area_rows(events);
+
+ Ok::<ResourceAreaListResponse, RpcError>(ResourceAreaListResponse {
+ resource_areas: items,
+ })
+ })?;
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::build_resource_area_rows;
+ use radroots_events::farm::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon};
+ use radroots_events::kinds::KIND_RESOURCE_AREA;
+ use radroots_events::resource_area::{
+ RadrootsResourceArea, RadrootsResourceAreaLocation,
+ };
+ use radroots_events_codec::resource_area::encode::resource_area_build_tags;
+ use radroots_nostr::prelude::RadrootsNostrEvent;
+ use serde_json::json;
+
+ fn resource_area_event(
+ id: &str,
+ pubkey: &str,
+ created_at: u64,
+ tags: Vec<Vec<String>>,
+ content: &str,
+ ) -> RadrootsNostrEvent {
+ let sig = format!("{:0128x}", 9);
+ let event_json = json!({
+ "id": id,
+ "pubkey": pubkey,
+ "created_at": created_at,
+ "kind": KIND_RESOURCE_AREA,
+ "tags": tags,
+ "content": content,
+ "sig": sig,
+ });
+ serde_json::from_value(event_json).expect("event")
+ }
+
+ fn sample_location() -> RadrootsResourceAreaLocation {
+ let point = RadrootsGeoJsonPoint {
+ r#type: "Point".to_string(),
+ coordinates: [-76.9714, -6.0346],
+ };
+ let polygon = RadrootsGeoJsonPolygon {
+ r#type: "Polygon".to_string(),
+ coordinates: vec![vec![
+ [-76.9714, -6.0346],
+ [-76.9712, -6.0346],
+ [-76.9712, -6.0344],
+ [-76.9714, -6.0344],
+ [-76.9714, -6.0346],
+ ]],
+ };
+ let gcs = RadrootsGcsLocation {
+ lat: -6.0346,
+ lng: -76.9714,
+ geohash: "6m6t5x".to_string(),
+ point,
+ polygon,
+ 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,
+ };
+ RadrootsResourceAreaLocation {
+ primary: Some("Moyobamba".to_string()),
+ city: None,
+ region: None,
+ country: None,
+ gcs,
+ }
+ }
+
+ fn sample_resource_area(d_tag: &str, name: &str) -> RadrootsResourceArea {
+ RadrootsResourceArea {
+ d_tag: d_tag.to_string(),
+ name: name.to_string(),
+ about: None,
+ location: sample_location(),
+ tags: None,
+ }
+ }
+
+ #[test]
+ fn resource_area_list_sorts_by_created_at_desc() {
+ let pubkey = "1bdebe7b23fccb167fc8843280b789839dfa296ae9fd86cc9769b4813d76d8a4";
+ let old_id = format!("{:064x}", 1);
+ let new_id = format!("{:064x}", 2);
+ let area = sample_resource_area("area-1", "Area One");
+ let content = serde_json::to_string(&area).expect("content");
+ let tags = resource_area_build_tags(&area).expect("tags");
+ let older = resource_area_event(&old_id, pubkey, 100, tags.clone(), &content);
+ let newer = resource_area_event(&new_id, pubkey, 200, tags.clone(), &content);
+
+ let areas = build_resource_area_rows(vec![older, newer]);
+
+ assert_eq!(areas.len(), 2);
+ assert_eq!(areas[0].event.id, new_id);
+ assert_eq!(areas[0].event.created_at, 200);
+ assert_eq!(areas[1].event.id, old_id);
+ assert_eq!(areas[1].event.created_at, 100);
+ }
+
+ #[test]
+ fn resource_area_list_uses_tag_d_when_missing_in_content() {
+ let pubkey = "2bdebe7b23fccb167fc8843280b789839dfa296ae9fd86cc9769b4813d76d8a4";
+ let area = sample_resource_area("area-1", "Area One");
+ let tags = resource_area_build_tags(&area).expect("tags");
+ let content_area = sample_resource_area("", "Area One");
+ let content = serde_json::to_string(&content_area).expect("content");
+ let id = format!("{:064x}", 3);
+ let event = resource_area_event(&id, pubkey, 300, tags.clone(), &content);
+
+ let areas = build_resource_area_rows(vec![event]);
+
+ assert_eq!(areas.len(), 1);
+ assert_eq!(areas[0].event.tags, tags);
+ let parsed = areas[0].resource_area.as_ref().expect("area");
+ assert_eq!(parsed.d_tag, "area-1");
+ assert_eq!(parsed.name, "Area One");
+ }
+}
diff --git a/src/api/jsonrpc/methods/events/resource_area/mod.rs b/src/api/jsonrpc/methods/events/resource_area/mod.rs
@@ -0,0 +1,14 @@
+use anyhow::Result;
+use jsonrpsee::server::RpcModule;
+
+use crate::api::jsonrpc::{MethodRegistry, RpcContext};
+
+pub mod list;
+pub mod publish;
+
+pub fn module(ctx: RpcContext, registry: MethodRegistry) -> Result<RpcModule<RpcContext>> {
+ let mut m = RpcModule::new(ctx);
+ list::register(&mut m, ®istry)?;
+ publish::register(&mut m, ®istry)?;
+ Ok(m)
+}
diff --git a/src/api/jsonrpc/methods/events/resource_area/publish.rs b/src/api/jsonrpc/methods/events/resource_area/publish.rs
@@ -0,0 +1,51 @@
+use anyhow::Result;
+use jsonrpsee::server::RpcModule;
+use serde::Deserialize;
+
+use crate::api::jsonrpc::nostr::{publish_response, PublishResponse};
+use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError};
+use radroots_events::kinds::KIND_RESOURCE_AREA;
+use radroots_events::resource_area::RadrootsResourceArea;
+use radroots_events_codec::resource_area::encode::resource_area_build_tags;
+use radroots_nostr::prelude::{radroots_nostr_build_event, radroots_nostr_send_event};
+
+#[derive(Debug, Deserialize)]
+struct PublishResourceAreaParams {
+ resource_area: RadrootsResourceArea,
+ #[serde(default)]
+ tags: Option<Vec<Vec<String>>>,
+}
+
+pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> {
+ registry.track("events.resource_area.publish");
+ m.register_async_method("events.resource_area.publish", |params, ctx, _| async move {
+ let relays = ctx.state.client.relays().await;
+ if relays.is_empty() {
+ return Err(RpcError::NoRelays);
+ }
+
+ let PublishResourceAreaParams { resource_area, tags } = params
+ .parse()
+ .map_err(|e| RpcError::InvalidParams(e.to_string()))?;
+
+ let content = serde_json::to_string(&resource_area).map_err(|e| {
+ RpcError::InvalidParams(format!("invalid resource_area json: {e}"))
+ })?;
+ let mut tag_slices = resource_area_build_tags(&resource_area)
+ .map_err(|e| RpcError::InvalidParams(e.to_string()))?;
+ if let Some(extra_tags) = tags {
+ tag_slices.extend(extra_tags);
+ }
+
+ let builder = radroots_nostr_build_event(KIND_RESOURCE_AREA, content, tag_slices)
+ .map_err(|e| RpcError::Other(format!("failed to build resource_area event: {e}")))?;
+
+ let output = radroots_nostr_send_event(&ctx.state.client, builder)
+ .await
+ .map_err(|e| RpcError::Other(format!("failed to publish resource_area: {e}")))?;
+
+ Ok::<PublishResponse, RpcError>(publish_response(output))
+ })?;
+
+ Ok(())
+}
diff --git a/src/api/jsonrpc/methods/events/resource_cap/list.rs b/src/api/jsonrpc/methods/events/resource_cap/list.rs
@@ -0,0 +1,199 @@
+use anyhow::Result;
+use jsonrpsee::server::RpcModule;
+use serde::Serialize;
+use std::time::Duration;
+
+use crate::api::jsonrpc::nostr::{event_tags, event_view_with_tags, NostrEventView};
+use crate::api::jsonrpc::params::{
+ apply_time_bounds,
+ limit_or,
+ parse_pubkeys_opt,
+ timeout_or,
+ EventListParams,
+};
+use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError};
+use radroots_events::kinds::KIND_RESOURCE_HARVEST_CAP;
+use radroots_events::resource_cap::RadrootsResourceHarvestCap;
+use radroots_events_codec::resource_cap::decode::resource_harvest_cap_from_event;
+use radroots_nostr::prelude::{
+ RadrootsNostrEvent,
+ RadrootsNostrFilter,
+ RadrootsNostrKind,
+};
+
+#[derive(Clone, Debug, Serialize)]
+struct ResourceCapEventFlat {
+ #[serde(flatten)]
+ event: NostrEventView,
+ resource_cap: Option<RadrootsResourceHarvestCap>,
+}
+
+#[derive(Clone, Debug, Serialize)]
+struct ResourceCapListResponse {
+ resource_caps: Vec<ResourceCapEventFlat>,
+}
+
+fn build_resource_cap_rows<I>(events: I) -> Vec<ResourceCapEventFlat>
+where
+ I: IntoIterator<Item = RadrootsNostrEvent>,
+{
+ let mut items = events
+ .into_iter()
+ .map(|ev| {
+ let tags = event_tags(&ev);
+ let kind = ev.kind.as_u16() as u32;
+ let resource_cap = resource_harvest_cap_from_event(kind, &tags, &ev.content).ok();
+ ResourceCapEventFlat {
+ event: event_view_with_tags(&ev, tags),
+ resource_cap,
+ }
+ })
+ .collect::<Vec<_>>();
+ items.sort_by(|a, b| b.event.created_at.cmp(&a.event.created_at));
+ items
+}
+
+pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> {
+ registry.track("events.resource_cap.list");
+ m.register_async_method("events.resource_cap.list", |params, ctx, _| async move {
+ if ctx.state.client.relays().await.is_empty() {
+ return Err(RpcError::NoRelays);
+ }
+
+ let EventListParams {
+ authors,
+ limit,
+ since,
+ until,
+ timeout_secs,
+ } = params
+ .parse::<Option<EventListParams>>()
+ .map_err(|e| RpcError::InvalidParams(e.to_string()))?
+ .unwrap_or_default();
+
+ let limit = limit_or(limit);
+
+ let mut filter = RadrootsNostrFilter::new()
+ .limit(limit)
+ .kind(RadrootsNostrKind::Custom(KIND_RESOURCE_HARVEST_CAP as u16));
+
+ if let Some(authors) = parse_pubkeys_opt("author", authors)? {
+ filter = filter.authors(authors);
+ } else {
+ filter = filter.author(ctx.state.pubkey);
+ }
+ filter = apply_time_bounds(filter, since, until);
+
+ let events = ctx
+ .state
+ .client
+ .fetch_events(filter, Duration::from_secs(timeout_or(timeout_secs)))
+ .await
+ .map_err(|e| RpcError::Other(format!("fetch failed: {e}")))?;
+
+ let items = build_resource_cap_rows(events);
+
+ Ok::<ResourceCapListResponse, RpcError>(ResourceCapListResponse {
+ resource_caps: items,
+ })
+ })?;
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::build_resource_cap_rows;
+ use radroots_core::{RadrootsCoreDecimal, RadrootsCoreQuantity, RadrootsCoreUnit};
+ use radroots_events::kinds::KIND_RESOURCE_HARVEST_CAP;
+ use radroots_events::resource_area::RadrootsResourceAreaRef;
+ use radroots_events::resource_cap::{RadrootsResourceHarvestCap, RadrootsResourceHarvestProduct};
+ use radroots_events_codec::resource_cap::encode::resource_harvest_cap_build_tags;
+ use radroots_nostr::prelude::RadrootsNostrEvent;
+ use serde_json::json;
+
+ fn resource_cap_event(
+ id: &str,
+ pubkey: &str,
+ created_at: u64,
+ tags: Vec<Vec<String>>,
+ content: &str,
+ ) -> RadrootsNostrEvent {
+ let sig = format!("{:0128x}", 10);
+ let event_json = json!({
+ "id": id,
+ "pubkey": pubkey,
+ "created_at": created_at,
+ "kind": KIND_RESOURCE_HARVEST_CAP,
+ "tags": tags,
+ "content": content,
+ "sig": sig,
+ });
+ serde_json::from_value(event_json).expect("event")
+ }
+
+ fn sample_cap(d_tag: &str, area_pubkey: &str, area_d_tag: &str) -> RadrootsResourceHarvestCap {
+ let quantity = RadrootsCoreQuantity::new(
+ RadrootsCoreDecimal::from(100_u64),
+ RadrootsCoreUnit::MassG,
+ );
+ RadrootsResourceHarvestCap {
+ d_tag: d_tag.to_string(),
+ resource_area: RadrootsResourceAreaRef {
+ pubkey: area_pubkey.to_string(),
+ d_tag: area_d_tag.to_string(),
+ },
+ product: RadrootsResourceHarvestProduct {
+ key: "coffee".to_string(),
+ category: None,
+ },
+ start: 100,
+ end: 200,
+ cap_quantity: quantity,
+ display_amount: None,
+ display_unit: None,
+ display_label: None,
+ tags: None,
+ }
+ }
+
+ #[test]
+ fn resource_cap_list_sorts_by_created_at_desc() {
+ let pubkey = "1bdebe7b23fccb167fc8843280b789839dfa296ae9fd86cc9769b4813d76d8a4";
+ let old_id = format!("{:064x}", 1);
+ let new_id = format!("{:064x}", 2);
+ let cap = sample_cap("cap-1", pubkey, "area-1");
+ let content = serde_json::to_string(&cap).expect("content");
+ let tags = resource_harvest_cap_build_tags(&cap).expect("tags");
+ let older = resource_cap_event(&old_id, pubkey, 100, tags.clone(), &content);
+ let newer = resource_cap_event(&new_id, pubkey, 200, tags.clone(), &content);
+
+ let caps = build_resource_cap_rows(vec![older, newer]);
+
+ assert_eq!(caps.len(), 2);
+ assert_eq!(caps[0].event.id, new_id);
+ assert_eq!(caps[0].event.created_at, 200);
+ assert_eq!(caps[1].event.id, old_id);
+ assert_eq!(caps[1].event.created_at, 100);
+ }
+
+ #[test]
+ fn resource_cap_list_uses_tag_d_when_missing_in_content() {
+ let pubkey = "2bdebe7b23fccb167fc8843280b789839dfa296ae9fd86cc9769b4813d76d8a4";
+ let cap = sample_cap("cap-1", pubkey, "area-1");
+ let tags = resource_harvest_cap_build_tags(&cap).expect("tags");
+ let mut content_cap = sample_cap("", pubkey, "area-1");
+ content_cap.display_label = Some("display".to_string());
+ let content = serde_json::to_string(&content_cap).expect("content");
+ let id = format!("{:064x}", 3);
+ let event = resource_cap_event(&id, pubkey, 300, tags.clone(), &content);
+
+ let caps = build_resource_cap_rows(vec![event]);
+
+ assert_eq!(caps.len(), 1);
+ assert_eq!(caps[0].event.tags, tags);
+ let parsed = caps[0].resource_cap.as_ref().expect("cap");
+ assert_eq!(parsed.d_tag, "cap-1");
+ assert_eq!(parsed.resource_area.d_tag, "area-1");
+ assert_eq!(parsed.product.key, "coffee");
+ }
+}
diff --git a/src/api/jsonrpc/methods/events/resource_cap/mod.rs b/src/api/jsonrpc/methods/events/resource_cap/mod.rs
@@ -0,0 +1,14 @@
+use anyhow::Result;
+use jsonrpsee::server::RpcModule;
+
+use crate::api::jsonrpc::{MethodRegistry, RpcContext};
+
+pub mod list;
+pub mod publish;
+
+pub fn module(ctx: RpcContext, registry: MethodRegistry) -> Result<RpcModule<RpcContext>> {
+ let mut m = RpcModule::new(ctx);
+ list::register(&mut m, ®istry)?;
+ publish::register(&mut m, ®istry)?;
+ Ok(m)
+}
diff --git a/src/api/jsonrpc/methods/events/resource_cap/publish.rs b/src/api/jsonrpc/methods/events/resource_cap/publish.rs
@@ -0,0 +1,51 @@
+use anyhow::Result;
+use jsonrpsee::server::RpcModule;
+use serde::Deserialize;
+
+use crate::api::jsonrpc::nostr::{publish_response, PublishResponse};
+use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError};
+use radroots_events::kinds::KIND_RESOURCE_HARVEST_CAP;
+use radroots_events::resource_cap::RadrootsResourceHarvestCap;
+use radroots_events_codec::resource_cap::encode::resource_harvest_cap_build_tags;
+use radroots_nostr::prelude::{radroots_nostr_build_event, radroots_nostr_send_event};
+
+#[derive(Debug, Deserialize)]
+struct PublishResourceCapParams {
+ resource_cap: RadrootsResourceHarvestCap,
+ #[serde(default)]
+ tags: Option<Vec<Vec<String>>>,
+}
+
+pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> {
+ registry.track("events.resource_cap.publish");
+ m.register_async_method("events.resource_cap.publish", |params, ctx, _| async move {
+ let relays = ctx.state.client.relays().await;
+ if relays.is_empty() {
+ return Err(RpcError::NoRelays);
+ }
+
+ let PublishResourceCapParams { resource_cap, tags } = params
+ .parse()
+ .map_err(|e| RpcError::InvalidParams(e.to_string()))?;
+
+ let content = serde_json::to_string(&resource_cap).map_err(|e| {
+ RpcError::InvalidParams(format!("invalid resource_cap json: {e}"))
+ })?;
+ let mut tag_slices = resource_harvest_cap_build_tags(&resource_cap)
+ .map_err(|e| RpcError::InvalidParams(e.to_string()))?;
+ if let Some(extra_tags) = tags {
+ tag_slices.extend(extra_tags);
+ }
+
+ let builder = radroots_nostr_build_event(KIND_RESOURCE_HARVEST_CAP, content, tag_slices)
+ .map_err(|e| RpcError::Other(format!("failed to build resource_cap event: {e}")))?;
+
+ let output = radroots_nostr_send_event(&ctx.state.client, builder)
+ .await
+ .map_err(|e| RpcError::Other(format!("failed to publish resource_cap: {e}")))?;
+
+ Ok::<PublishResponse, RpcError>(publish_response(output))
+ })?;
+
+ Ok(())
+}
diff --git a/src/api/jsonrpc/methods/mod.rs b/src/api/jsonrpc/methods/mod.rs
@@ -22,6 +22,8 @@ pub fn register_all(
root.merge(events::listing::module(ctx.clone(), registry.clone())?)?;
root.merge(events::farm::module(ctx.clone(), registry.clone())?)?;
root.merge(events::plot::module(ctx.clone(), registry.clone())?)?;
+ root.merge(events::resource_area::module(ctx.clone(), registry.clone())?)?;
+ root.merge(events::resource_cap::module(ctx.clone(), registry.clone())?)?;
root.merge(domains::trade::module(ctx, registry)?)?;
Ok(())
}