commit 390a618e592f20acf6f51027e838cf56ba591300
parent e24ba61ab3bbd60d3722e88bad5c7a9ce6e28ed8
Author: triesap <tyson@radroots.org>
Date: Sat, 6 Jun 2026 03:22:55 -0700
http: add listing comments endpoint
Diffstat:
2 files changed, 326 insertions(+), 5 deletions(-)
diff --git a/crates/tangle/tests/run_integration.rs b/crates/tangle/tests/run_integration.rs
@@ -11,7 +11,8 @@ use std::time::{Duration, Instant};
use tangle_protocol::event_to_value;
use tangle_store_surreal::{SurrealConnectionConfig, SurrealStore};
use tangle_test_support::{
- FixtureKey, auth_event_spec, build_fixture_event, valid_public_listing_spec,
+ FixtureKey, auth_event_spec, build_fixture_event, build_fixture_event_from_parts,
+ valid_public_listing_spec,
};
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message;
@@ -51,6 +52,7 @@ async fn tangle_run_serves_relay_clients_and_persists_surreal_state() {
assert!(nip11.contains("\"supported_nips\""));
let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
+ let comment = listing_comment(&listing, 1_714_124_436, "Can I pickup Saturday?");
let auth = build_fixture_event(&auth_event_spec()).expect("auth");
let seller = FixtureKey::Seller.public_key();
@@ -138,6 +140,29 @@ async fn tangle_run_serves_relay_clients_and_persists_surreal_state() {
assert_eq!(fetched[2]["id"], listing.id().as_str());
assert_eq!(next_label(&mut publisher).await, "EOSE");
+ publisher
+ .send(Message::Text(
+ serde_json::json!(["EVENT", event_to_value(&comment)])
+ .to_string()
+ .into(),
+ ))
+ .await
+ .expect("comment send");
+ assert_ok(&next_json(&mut publisher).await, true);
+ publisher
+ .send(Message::Text(
+ serde_json::json!(["REQ", "sub-comment", { "ids": [comment.id().as_str()] }])
+ .to_string()
+ .into(),
+ ))
+ .await
+ .expect("comment fetch send");
+ let fetched_comment = next_json(&mut publisher).await;
+ assert_eq!(fetched_comment[0], "EVENT");
+ assert_eq!(fetched_comment[1], "sub-comment");
+ assert_eq!(fetched_comment[2]["id"], comment.id().as_str());
+ assert_eq!(next_label(&mut publisher).await, "EOSE");
+
subscriber
.send(Message::Text(
serde_json::json!(["CLOSE", "sub-live"]).to_string().into(),
@@ -156,6 +181,16 @@ async fn tangle_run_serves_relay_clients_and_persists_surreal_state() {
assert!(detail.contains("200 OK"));
assert!(detail.contains("listing-a"));
assert!(detail.contains("Carrot bunches"));
+ let comments = http_get(
+ port,
+ &format!(
+ "/api/listings/{}/listing-a/comments?limit=5",
+ seller.as_str()
+ ),
+ );
+ assert!(comments.contains("200 OK"));
+ assert!(comments.contains(comment.id().as_str()));
+ assert!(comments.contains("Can I pickup Saturday?"));
let search = http_get(port, "/api/search?q=carrots&limit=5");
assert!(search.contains("200 OK"));
assert!(search.contains(listing.id().as_str()));
@@ -191,6 +226,14 @@ async fn tangle_run_serves_relay_clients_and_persists_surreal_state() {
.expect("listing row")
.is_some()
);
+ let comment_row = store
+ .comment_projection_row(comment.id())
+ .await
+ .expect("comment row")
+ .expect("comment row exists");
+ assert_eq!(comment_row["event_id"], comment.id().as_str());
+ assert_eq!(comment_row["root_ref"], listing_key);
+ assert_eq!(comment_row["content"], "Can I pickup Saturday?");
assert!(
store
.search_document_row(&listing_key)
@@ -751,6 +794,35 @@ fn assert_ok(message: &Value, accepted: bool) {
assert_eq!(message[2], accepted);
}
+fn listing_comment(
+ listing: &tangle_protocol::Event,
+ created_at: u64,
+ content: &str,
+) -> tangle_protocol::Event {
+ let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
+ build_fixture_event_from_parts(
+ FixtureKey::Seller,
+ created_at,
+ 1_111,
+ vec![
+ vec!["A".to_owned(), listing_key.clone()],
+ vec!["K".to_owned(), "30402".to_owned()],
+ vec![
+ "P".to_owned(),
+ listing.unsigned().pubkey().as_str().to_owned(),
+ ],
+ vec!["a".to_owned(), listing_key],
+ vec!["k".to_owned(), "30402".to_owned()],
+ vec![
+ "p".to_owned(),
+ listing.unsigned().pubkey().as_str().to_owned(),
+ ],
+ ],
+ content,
+ )
+ .expect("comment event")
+}
+
fn stop_relay(mut relay: Child) {
stop_child(&mut relay);
let status = relay.wait().expect("relay exit");
diff --git a/crates/tangle_runtime/src/lib.rs b/crates/tangle_runtime/src/lib.rs
@@ -35,8 +35,9 @@ use tangle_protocol::{
};
use tangle_store::{StoreEventOutcome, StoredEvent};
use tangle_store_surreal::{
- DurableRateLimitDecision, ListingProjectionQuery, MigrationApplyOutcome, SearchDocumentQuery,
- SurrealConnectionConfig, SurrealConnectionMode, SurrealStore, base_migration_plan,
+ CommentProjectionOutcome, CommentProjectionQuery, DurableRateLimitDecision,
+ ListingProjectionQuery, MigrationApplyOutcome, SearchDocumentQuery, SurrealConnectionConfig,
+ SurrealConnectionMode, SurrealStore, base_migration_plan,
};
use tokio::net::TcpListener;
use tokio::sync::broadcast;
@@ -591,6 +592,11 @@ async fn project_stored_event(
{
return Err(RuntimeCommandError::store("event projection failed"));
}
+ let comment_projected = match store.project_comment(event, now).await {
+ Ok(CommentProjectionOutcome::Projected) => true,
+ Ok(CommentProjectionOutcome::NotComment | CommentProjectionOutcome::Ineligible) => false,
+ Err(_) => return Err(RuntimeCommandError::store("event projection failed")),
+ };
if effect == AdmissionEffect::StoreRawAndProjectPublicListing {
if store.project_current_listing(event, now).await.is_err()
|| store.project_listing_helpers(event).await.is_err()
@@ -600,7 +606,7 @@ async fn project_stored_event(
}
return Ok(true);
}
- Ok(false)
+ Ok(comment_projected)
}
fn parse_event_import_document(raw: &str) -> Result<Vec<Event>, RuntimeCommandError> {
@@ -953,6 +959,10 @@ fn runtime_router(
.route("/readyz", get(runtime_readyz))
.route("/api/listings", get(runtime_listings))
.route("/api/listings/{pubkey}/{d}", get(runtime_listing_detail))
+ .route(
+ "/api/listings/{pubkey}/{d}/comments",
+ get(runtime_listing_comments),
+ )
.route("/api/search", get(runtime_marketplace_search))
.route("/api/sellers/{pubkey}", get(runtime_seller_detail))
.route(
@@ -1029,6 +1039,22 @@ async fn runtime_listing_detail(
.await
}
+async fn runtime_listing_comments(
+ State(state): State<RuntimeRelayState>,
+ Path((pubkey, d)): Path<(String, String)>,
+ RawQuery(query): RawQuery,
+) -> Result<Json<ListingCommentsDocument>, ApiError> {
+ listing_comments(
+ State(ListingsHttpState::new(
+ state.store.clone(),
+ state.config.limits(),
+ )),
+ Path((pubkey, d)),
+ RawQuery(query),
+ )
+ .await
+}
+
async fn runtime_marketplace_search(
State(state): State<RuntimeRelayState>,
RawQuery(query): RawQuery,
@@ -1860,6 +1886,7 @@ impl EventMessageHandler {
.store_listing_revision(&event, now)
.await
.is_err()
+ || self.store.project_comment(&event, now).await.is_err()
{
return ok_rejected(event_id, "error: projection failed".to_owned());
}
@@ -2517,6 +2544,30 @@ pub struct ListingDetailDocument {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ListingCommentsDocument {
+ pub items: Vec<CommentItemDocument>,
+ pub next_cursor: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct CommentItemDocument {
+ pub event_id: String,
+ pub pubkey: String,
+ pub created_at: u64,
+ pub content: String,
+ pub root: CommentReferenceDocument,
+ pub parent: CommentReferenceDocument,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct CommentReferenceDocument {
+ pub target_type: String,
+ pub target_ref: String,
+ pub kind: String,
+ pub author: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SellerDocument {
pub pubkey: String,
pub approved: bool,
@@ -2719,6 +2770,7 @@ pub fn listings_router(state: ListingsHttpState) -> Router {
Router::new()
.route("/api/listings", get(listings))
.route("/api/listings/{pubkey}/{d}", get(listing_detail))
+ .route("/api/listings/{pubkey}/{d}/comments", get(listing_comments))
.route("/api/search", get(marketplace_search))
.route("/api/sellers/{pubkey}", get(seller_detail))
.with_state(state)
@@ -2823,6 +2875,43 @@ async fn listing_detail(
}))
}
+async fn listing_comments(
+ State(state): State<ListingsHttpState>,
+ Path((pubkey, d)): Path<(String, String)>,
+ RawQuery(query): RawQuery,
+) -> Result<Json<ListingCommentsDocument>, ApiError> {
+ let pubkey = parse_pubkey("pubkey", &pubkey)?;
+ let d = required_value("d", &d)?;
+ let limit = parse_comment_query(query.as_deref().unwrap_or_default())?;
+ let listing_key = format!("30402:{}:{d}", pubkey.as_str());
+ let listing = state
+ .store
+ .listing_current_row(&listing_key)
+ .await
+ .map_err(|_| ApiError::internal())?
+ .ok_or_else(|| ApiError::not_found("listing not found"))?;
+ if bool_field(&listing, "hidden")? || bool_field(&listing, "deleted")? {
+ return Err(ApiError::not_found("listing not found"));
+ }
+ let rows = state
+ .store
+ .query_comment_projections(
+ &CommentProjectionQuery::new()
+ .with_root("address", &listing_key)
+ .with_limit(limit),
+ )
+ .await
+ .map_err(|_| ApiError::internal())?;
+ let items = rows
+ .iter()
+ .map(comment_item_document)
+ .collect::<Result<Vec<_>, _>>()?;
+ Ok(Json(ListingCommentsDocument {
+ items,
+ next_cursor: None,
+ }))
+}
+
async fn marketplace_search(
State(state): State<ListingsHttpState>,
RawQuery(query): RawQuery,
@@ -3039,6 +3128,23 @@ fn search_document_query(parsed: &MarketplaceSearchHttpQuery) -> SearchDocumentQ
query
}
+fn parse_comment_query(raw: &str) -> Result<u64, ApiError> {
+ let mut limit = None;
+ for (key, value) in form_urlencoded::parse(raw.as_bytes()) {
+ match key.as_ref() {
+ "limit" => set_once("limit", &mut limit, parse_limit(value.as_ref())?)?,
+ "" => {}
+ _ => {
+ return Err(ApiError::invalid_request(format!(
+ "{} is not supported by the listing comments endpoint",
+ key
+ )));
+ }
+ }
+ }
+ Ok(limit.unwrap_or(50))
+}
+
fn listing_item_document(row: &serde_json::Value) -> Result<ListingItemDocument, ApiError> {
Ok(ListingItemDocument {
listing_key: string_field(row, "listing_key")?,
@@ -3062,6 +3168,27 @@ fn listing_item_document(row: &serde_json::Value) -> Result<ListingItemDocument,
})
}
+fn comment_item_document(row: &serde_json::Value) -> Result<CommentItemDocument, ApiError> {
+ Ok(CommentItemDocument {
+ event_id: string_field(row, "event_id")?,
+ pubkey: string_field(row, "pubkey")?,
+ created_at: u64_field(row, "created_at")?,
+ content: string_field(row, "content")?,
+ root: CommentReferenceDocument {
+ target_type: string_field(row, "root_target_type")?,
+ target_ref: string_field(row, "root_ref")?,
+ kind: string_field(row, "root_kind")?,
+ author: optional_string_field(row, "root_author")?,
+ },
+ parent: CommentReferenceDocument {
+ target_type: string_field(row, "parent_target_type")?,
+ target_ref: string_field(row, "parent_ref")?,
+ kind: string_field(row, "parent_kind")?,
+ author: optional_string_field(row, "parent_author")?,
+ },
+ })
+}
+
fn fulfillment_document(row: &serde_json::Value) -> Result<Vec<String>, ApiError> {
let mut fulfillment = Vec::new();
if bool_field(row, "pickup_available")? {
@@ -3392,7 +3519,10 @@ mod tests {
use tangle_store_surreal::{
SurrealConnectionConfig, SurrealConnectionMode, SurrealStore, base_migration_plan,
};
- use tangle_test_support::{auth_event_spec, build_fixture_event, valid_public_listing_spec};
+ use tangle_test_support::{
+ FixtureKey, auth_event_spec, build_fixture_event, build_fixture_event_from_parts,
+ valid_public_listing_spec,
+ };
use tower::ServiceExt;
#[test]
@@ -5312,6 +5442,96 @@ mod tests {
}
#[tokio::test]
+ async fn listing_comments_endpoint_returns_visible_projected_comments() {
+ let store = runtime_memory_store().await;
+ let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
+ let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
+ let comment = listing_comment(&listing, 1_714_125_410, "Can I pickup Saturday?");
+ store
+ .project_current_listing(&listing, UnixTimestamp::new(1_714_125_409))
+ .await
+ .expect("project listing");
+ store
+ .project_comment(&comment, UnixTimestamp::new(1_714_125_411))
+ .await
+ .expect("project comment");
+
+ let uri = format!(
+ "/api/listings/{}/listing-a/comments?limit=5",
+ listing.unsigned().pubkey().as_str()
+ );
+ let response = listings_router(ListingsHttpState::new(
+ store.clone(),
+ RuntimeLimits::default(),
+ ))
+ .oneshot(
+ Request::builder()
+ .uri(uri.as_str())
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("response");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = axum::body::to_bytes(response.into_body(), usize::MAX)
+ .await
+ .expect("body");
+ assert_eq!(
+ serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
+ serde_json::json!({
+ "items": [{
+ "event_id": comment.id().as_str(),
+ "pubkey": FixtureKey::Buyer.public_key().as_str(),
+ "created_at": 1714125410_u64,
+ "content": "Can I pickup Saturday?",
+ "root": {
+ "target_type": "address",
+ "target_ref": listing_key,
+ "kind": "30402",
+ "author": listing.unsigned().pubkey().as_str()
+ },
+ "parent": {
+ "target_type": "address",
+ "target_ref": listing_key,
+ "kind": "30402",
+ "author": listing.unsigned().pubkey().as_str()
+ }
+ }],
+ "next_cursor": null
+ })
+ );
+
+ store
+ .database()
+ .query("UPDATE comment_projection SET hidden = true WHERE event_id = $event_id;")
+ .bind(("event_id", comment.id().as_str()))
+ .await
+ .expect("hide comment")
+ .check()
+ .expect("hide comment check");
+ let response = listings_router(ListingsHttpState::new(store, RuntimeLimits::default()))
+ .oneshot(
+ Request::builder()
+ .uri(uri.as_str())
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("response");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = axum::body::to_bytes(response.into_body(), usize::MAX)
+ .await
+ .expect("body");
+ assert_eq!(
+ serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
+ serde_json::json!({
+ "items": [],
+ "next_cursor": null
+ })
+ );
+ }
+
+ #[tokio::test]
async fn listing_detail_endpoint_rejects_invalid_or_missing_listing() {
let store = runtime_memory_store().await;
let response = listings_router(ListingsHttpState::new(
@@ -5544,6 +5764,35 @@ mod tests {
store
}
+ fn listing_comment(
+ listing: &tangle_protocol::Event,
+ created_at: u64,
+ content: &str,
+ ) -> tangle_protocol::Event {
+ let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
+ build_fixture_event_from_parts(
+ FixtureKey::Buyer,
+ created_at,
+ 1_111,
+ vec![
+ vec!["A".to_owned(), listing_key.clone()],
+ vec!["K".to_owned(), "30402".to_owned()],
+ vec![
+ "P".to_owned(),
+ listing.unsigned().pubkey().as_str().to_owned(),
+ ],
+ vec!["a".to_owned(), listing_key],
+ vec!["k".to_owned(), "30402".to_owned()],
+ vec![
+ "p".to_owned(),
+ listing.unsigned().pubkey().as_str().to_owned(),
+ ],
+ ],
+ content,
+ )
+ .expect("comment event")
+ }
+
fn runtime_client_message_loop() -> ClientMessageLoop {
let mut connection = runtime_connection("client-loop");
connection.set_remote_addr("127.0.0.1:7777");