diff --git a/backend/README.md b/backend/README.md index 4943d716..dd97fec6 100644 --- a/backend/README.md +++ b/backend/README.md @@ -231,3 +231,57 @@ let app = Router::new() .route("/", get(handler)) .layer(LoggingLayer); ``` + +--- + +## `GET /api/v1/markets/:id/predictions` — cursor-paginated predictions per market + +Returns the predictions placed in a specific prediction market (pool), ordered +newest first, with cursor-based pagination. + +### Query parameters + +| Parameter | Type | Required | Description | +| :-------- | :------ | :------- | :---------------------------------------------------------------- | +| `after` | integer | No | Cursor value from the previous page's `next_cursor` field | +| `limit` | integer | No | Page size, 1–100 (default 20) | + +### Example — first page + +```bash +curl "http://localhost:3000/api/v1/markets/42/predictions?limit=3" +``` + +```json +{ + "status": "success", + "data": { + "market_id": 42, + "predictions": [ + { "id": 305, "pool_id": 42, "user_address": "GABC...", "outcome": 1, "amount": 500, "created_at": "2026-06-29T08:00:00Z" }, + { "id": 304, "pool_id": 42, "user_address": "GDEF...", "outcome": 0, "amount": 200, "created_at": "2026-06-28T22:30:00Z" }, + { "id": 302, "pool_id": 42, "user_address": "GXYZ...", "outcome": 1, "amount": 100, "created_at": "2026-06-28T10:15:00Z" } + ], + "total": 47, + "limit": 3, + "next_cursor": 302 + } +} +``` + +### Example — next page + +```bash +curl "http://localhost:3000/api/v1/markets/42/predictions?limit=3&after=302" +``` + +Pass `next_cursor` from the previous response as `after`. When `next_cursor` is +`null` you have reached the last page. + +### Error responses + +| Status | Code | When | +| :----- | :--------------------- | :------------------------------- | +| 404 | `NOT_FOUND` | The market ID does not exist | +| 503 | `DATABASE_UNAVAILABLE` | No database pool configured | +| 500 | `INTERNAL_ERROR` | Unexpected database query error | diff --git a/backend/src/db.rs b/backend/src/db.rs index 6c9b33ae..82f5d761 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -888,6 +888,66 @@ pub async fn insert_prediction_from_event( Ok(()) } +// ── Market Predictions (cursor-based pagination) ────────────────────────────── + +/// A single prediction within a market (pool), returned by the market predictions list. +#[derive(Debug, serde::Serialize, sqlx::FromRow)] +pub struct MarketPredictionRow { + /// Stable row ID, also used as the pagination cursor. + pub id: i64, + pub pool_id: i64, + pub user_address: String, + pub outcome: i32, + pub amount: i64, + pub created_at: chrono::DateTime, +} + +/// Fetch a cursor-paginated page of predictions for a given pool. +/// +/// `after_id` is the opaque cursor from the previous page (`data.next_cursor`). +/// Pass `None` (or omit the query param) to start from the most recent prediction. +/// +/// Results are ordered `id DESC` (newest first), which is stable under +/// concurrent inserts because new rows always get larger IDs. +pub async fn get_market_predictions( + pool: &PgPool, + pool_id: i64, + after_id: Option, + limit: i64, +) -> Result, sqlx::Error> { + // Requesting one extra row lets us detect whether a next page exists + // without a separate COUNT query on the hot path. + let fetch_limit = limit + 1; + + let sql = r#" + SELECT id, pool_id, user_address, outcome, amount, created_at + FROM predictions + WHERE pool_id = $1 + AND ($2::bigint IS NULL OR id < $2) + ORDER BY id DESC + LIMIT $3 + "#; + + sqlx::query_as::<_, MarketPredictionRow>(sql) + .bind(pool_id) + .bind(after_id) + .bind(fetch_limit) + .fetch_all(pool) + .await +} + +/// Count total predictions for a given pool (used for the `total` field). +pub async fn count_market_predictions( + pool: &PgPool, + pool_id: i64, +) -> Result { + let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM predictions WHERE pool_id = $1") + .bind(pool_id) + .fetch_one(pool) + .await?; + Ok(count) +} + /// A single row in the referral earnings breakdown — one entry per pool. #[derive(Debug, serde::Serialize, sqlx::FromRow)] pub struct ReferralEarningRow { @@ -1311,3 +1371,102 @@ mod predictions_index_tests { ); } } + +#[cfg(test)] +mod market_predictions_tests { + use super::*; + use chrono::Utc; + + /// Helper: build a page of `MarketPredictionRow` with sequential IDs, + /// descending (newest-first) to match the query order. + fn make_rows(ids: &[i64]) -> Vec { + ids.iter() + .map(|&id| MarketPredictionRow { + id, + pool_id: 1, + user_address: format!("G{id:055}"), + outcome: 0, + amount: 100, + created_at: Utc::now(), + }) + .collect() + } + + // ── has_next / next_cursor logic (mirrors the handler) ────────────────── + + /// When the DB returns exactly `limit` rows there is no next page. + #[test] + fn no_next_page_when_rows_equal_limit() { + let limit = 3i64; + let mut rows = make_rows(&[10, 9, 8]); + let has_next = rows.len() as i64 > limit; + if has_next { + rows.truncate(limit as usize); + } + let next_cursor: Option = if has_next { rows.last().map(|r| r.id) } else { None }; + + assert!(!has_next); + assert_eq!(next_cursor, None); + assert_eq!(rows.len(), 3); + } + + /// When the DB returns `limit + 1` rows there IS a next page; the extra + /// row is trimmed and the cursor points to the last remaining row's ID. + #[test] + fn has_next_page_when_rows_exceed_limit() { + let limit = 3i64; + // Query fetches limit+1 = 4 rows + let mut rows = make_rows(&[10, 9, 8, 7]); + let has_next = rows.len() as i64 > limit; + if has_next { + rows.truncate(limit as usize); + } + let next_cursor: Option = if has_next { rows.last().map(|r| r.id) } else { None }; + + assert!(has_next); + assert_eq!(next_cursor, Some(8)); // last row after truncation + assert_eq!(rows.len(), 3); + } + + /// An empty result set produces no next cursor and returns an empty slice. + #[test] + fn empty_result_produces_no_cursor() { + let limit = 20i64; + let mut rows: Vec = vec![]; + let has_next = rows.len() as i64 > limit; + if has_next { + rows.truncate(limit as usize); + } + let next_cursor: Option = if has_next { rows.last().map(|r| r.id) } else { None }; + + assert!(!has_next); + assert_eq!(next_cursor, None); + assert!(rows.is_empty()); + } + + /// A single-row result with limit=1 should NOT show has_next. + /// (limit+1 = 2 fetched, only 1 returned → no next page) + #[test] + fn single_row_within_limit_has_no_next() { + let limit = 1i64; + let mut rows = make_rows(&[5]); + let has_next = rows.len() as i64 > limit; + if has_next { + rows.truncate(limit as usize); + } + let next_cursor: Option = if has_next { rows.last().map(|r| r.id) } else { None }; + + assert!(!has_next); + assert_eq!(next_cursor, None); + } + + /// `clamp(1, 100)` must reject zero and cap at 100. + #[test] + fn limit_clamp_boundaries() { + assert_eq!(0i64.clamp(1, 100), 1); + assert_eq!((-5i64).clamp(1, 100), 1); + assert_eq!(100i64.clamp(1, 100), 100); + assert_eq!(9999i64.clamp(1, 100), 100); + assert_eq!(50i64.clamp(1, 100), 50); + } +} diff --git a/backend/src/openapi.rs b/backend/src/openapi.rs index 327fc57d..d825af2c 100644 --- a/backend/src/openapi.rs +++ b/backend/src/openapi.rs @@ -49,6 +49,31 @@ pub struct PoolListResponse { pub sort_by: String, } +/// OpenAPI schema for a single prediction in the market predictions list. +#[derive(Debug, serde::Serialize, serde::Deserialize, ToSchema)] +pub struct MarketPredictionDoc { + /// Stable row ID; also used as the pagination cursor value. + pub id: i64, + pub pool_id: i64, + pub user_address: String, + pub outcome: i32, + pub amount: i64, + pub created_at: String, +} + +/// OpenAPI schema for the cursor-paginated market predictions response. +#[derive(Debug, serde::Serialize, serde::Deserialize, ToSchema)] +pub struct MarketPredictionsResponse { + pub market_id: i64, + pub predictions: Vec, + /// Total number of predictions for this market (across all pages). + pub total: i64, + pub limit: i64, + /// Opaque cursor: pass as `?after=` to retrieve the next page. + /// `null` when the current page is the last. + pub next_cursor: Option, +} + /// OpenAPI schema for a single prediction record. #[derive(Debug, serde::Serialize, serde::Deserialize, ToSchema)] pub struct PredictionDoc { @@ -240,6 +265,7 @@ pub struct IndexerOkResponse { api_get_leaderboard, api_get_user_history, api_get_user_predictions, + api_get_market_predictions, api_get_referrals, api_get_user_referral_earnings, api_ingest_pool_created, @@ -250,6 +276,8 @@ pub struct IndexerOkResponse { OutcomeOddsDoc, PoolWithOddsDoc, PoolListResponse, + MarketPredictionDoc, + MarketPredictionsResponse, PredictionDoc, PredictionHistoryResponse, UserPredictionDoc, @@ -371,6 +399,21 @@ async fn api_get_leaderboard() {} )] async fn api_get_user_history() {} +#[allow(dead_code)] +#[utoipa::path(get, path = "/api/v1/markets/{market_id}/predictions", tag = "predictions", + params( + ("market_id" = i64, Path, description = "On-chain pool (market) identifier"), + ("after" = Option, Query, description = "Cursor from previous page's `next_cursor` field"), + ("limit" = Option, Query, description = "Page size (1–100, default 20)"), + ), + responses( + (status = 200, description = "Cursor-paginated predictions for the market", body = MarketPredictionsResponse), + (status = 404, description = "Market not found", body = ErrorResponse), + (status = 503, description = "Database not available", body = ErrorResponse), + ) +)] +async fn api_get_market_predictions() {} + #[allow(dead_code)] #[utoipa::path(get, path = "/api/v1/users/{address}/predictions", tag = "predictions", params( diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 72b09c73..9ee47133 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -221,4 +221,73 @@ mod tests { "router_with_db should nest under /v1, got: {body}" ); } + + // ── Market predictions: no-DB guard ────────────────────────────────────── + + /// Without a DB the market predictions endpoint must return a structured + /// DATABASE_UNAVAILABLE error (not a panic or 500 without an envelope). + #[tokio::test] + async fn market_predictions_without_db_returns_database_unavailable() { + let response = build_router_without_db() + .oneshot(get("/v1/markets/1/predictions")) + .await + .expect("request failed"); + + // The current get_pools handler also returns 200 for DATABASE_UNAVAILABLE, + // so we follow the same convention here. + assert_eq!(response.status(), StatusCode::OK); + + let body = body_string(response.into_body()).await; + assert!( + body.contains("database not available") || body.contains("DATABASE_UNAVAILABLE"), + "should surface DATABASE_UNAVAILABLE, got: {body}" + ); + } + + /// A non-numeric market ID must return 422 (Axum path extraction failure). + #[tokio::test] + async fn market_predictions_bad_market_id_returns_422() { + let response = build_router_without_db() + .oneshot(get("/v1/markets/not-a-number/predictions")) + .await + .expect("request failed"); + + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + } + + /// `?limit` values outside the 1-100 range are clamped, not rejected. + /// With no DB the response is DATABASE_UNAVAILABLE – the important thing is + /// that no 500 / panic occurs from an unclamped limit. + #[tokio::test] + async fn market_predictions_large_limit_is_clamped() { + let response = build_router_without_db() + .oneshot(get("/v1/markets/1/predictions?limit=99999")) + .await + .expect("request failed"); + + // DATABASE_UNAVAILABLE path, but must not be a 500 from bad arithmetic. + assert_ne!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + /// With a lazy (unreachable) DB the handler must attempt a query and return + /// a DB error response, not the database-unavailable sentinel (which would + /// mean the pool was never wired through). + #[tokio::test] + async fn market_predictions_with_lazy_db_attempts_query() { + let response = build_router_with_lazy_db() + .oneshot(get("/v1/markets/1/predictions")) + .await + .expect("request failed"); + + let body = body_string(response.into_body()).await; + assert!( + !body.contains("database not available"), + "router_with_db should wire the pool; got: {body}" + ); + // Should either be a DB error or a 404 (market not found), never a panic. + assert!( + body.contains("\"error\""), + "should return an error envelope; got: {body}" + ); + } } diff --git a/backend/src/routes/v1.rs b/backend/src/routes/v1.rs index 634f0876..32fdda74 100644 --- a/backend/src/routes/v1.rs +++ b/backend/src/routes/v1.rs @@ -667,16 +667,103 @@ pub async fn ingest_prediction_placed( } } -/// Build the version 1 API router with per-route rate limiting. +// ── Market Predictions (cursor-based) ──────────────────────────────────────── + +/// Query parameters for `GET /api/v1/markets/:id/predictions`. +#[derive(Debug, Deserialize)] +pub struct MarketPredictionsQuery { + /// Opaque cursor: the `id` of the last item on the previous page. + /// Omit (or set to null) to start from the most recent prediction. + pub after: Option, + /// Page size (1–100, default 20). + pub limit: Option, +} + +/// `GET /api/v1/markets/:id/predictions` — cursor-paginated list of predictions +/// for a specific market (pool). /// -/// Routes are grouped into four rate-limit tiers (see [`crate::rate_limit`]): +/// ## Pagination +/// The response includes `next_cursor`: pass it as `?after=` on the +/// next request to fetch the following page. A `null` `next_cursor` means you +/// have reached the last page. /// -/// | Tier | Endpoints | Burst / period | -/// |-------|-------------------------------------------------|----------------| -/// | Light | `/`, `/health`, `/fees`, `/prices`, `/ws` | 120 / 60 s | -/// | Read | `/pools`, `/pools/:id`, `/stats`, `/leaderboard`, `/referrals/*` | 60 / 60 s | -/// | User | `/users/*` | 30 / 60 s | -/// | Write | `/indexer/*` | 20 / 60 s | +/// ## 404 behaviour +/// If the pool does not exist the handler returns 404 so callers can +/// distinguish "market not found" from "market has no predictions". +pub async fn get_market_predictions( + State(state): State, + Path(market_id): Path, + Query(params): Query, +) -> axum::response::Response { + use axum::http::StatusCode; + use axum::response::IntoResponse; + + // Clamp limit: 1..=100, default 20. + let limit = params.limit.unwrap_or(20).clamp(1, 100); + let after = params.after; + + let Some(db) = &state.db else { + return ApiResponse::<()>::error( + StatusCode::SERVICE_UNAVAILABLE, + error_codes::DATABASE_UNAVAILABLE, + "database not available", + ) + .into_response(); + }; + + // Verify the market exists first so we can return 404 instead of an empty list. + match crate::db::get_pool_by_id(db, market_id).await { + Ok(None) => { + return ApiResponse::<()>::error( + StatusCode::NOT_FOUND, + error_codes::NOT_FOUND, + "market not found", + ) + .into_response(); + } + Err(e) => { + return ApiResponse::<()>::error( + StatusCode::INTERNAL_SERVER_ERROR, + error_codes::INTERNAL_ERROR, + e.to_string(), + ) + .into_response(); + } + Ok(Some(_)) => {} + } + + match tokio::try_join!( + crate::db::get_market_predictions(db, market_id, after, limit), + crate::db::count_market_predictions(db, market_id), + ) { + Ok((mut rows, total)) => { + // The query fetches limit+1 rows; if we got the extra one there is + // another page available. + let has_next = rows.len() as i64 > limit; + if has_next { + rows.truncate(limit as usize); + } + let next_cursor: Option = if has_next { rows.last().map(|r| r.id) } else { None }; + + let response = serde_json::json!({ + "market_id": market_id, + "predictions": rows, + "total": total, + "limit": limit, + "next_cursor": next_cursor, + }); + ApiResponse::success(response).into_response() + } + Err(e) => ApiResponse::<()>::error( + StatusCode::INTERNAL_SERVER_ERROR, + error_codes::INTERNAL_ERROR, + e.to_string(), + ) + .into_response(), + } +} + +/// Build the version 1 API router. pub fn router( config: Arc, cache: PriceCache, @@ -741,10 +828,30 @@ pub fn router( ); Router::new() - .merge(light) - .merge(read) - .merge(user) - .merge(write) + .route("/", get(index)) + .route("/health", get(health)) + .route("/pools", get(get_pools)) + .route("/pools/:id", get(get_pool_by_id_handler)) + .route("/stats", get(get_stats)) + .route("/leaderboard", get(get_leaderboard)) + .route("/fees", get(get_fees)) + .route("/prices", get(crate::price_cache::get_prices)) + .route("/referrals/{address}", get(referrals_handler)) + .route( + "/referrals/{address}/estimate", + get(referral_estimate_handler), + ) + .route( + "/users/{address}/referrals", + get(user_referral_earnings_handler), + ) + .route("/users/{address}/history", get(get_user_history)) + .route("/users/{address}/predictions", get(get_user_predictions)) + .route("/markets/{id}/predictions", get(get_market_predictions)) + .route("/indexer/pool-created", post(ingest_pool_created)) + .route("/indexer/prediction-placed", post(ingest_prediction_placed)) + .route("/ws", get(crate::ws::ws_handler)) + .with_state(state) } /// `GET /api/v1/fees` — reads fee config from the shared AppState.