1+ /**
2+ * GET /api/markets/:id/predictions
3+ *
4+ * Public endpoint that returns cursor-paginated predictions for a specific market.
5+ * No authentication required.
6+ *
7+ * Query parameters:
8+ * - status (optional) — filter by prediction status (pending, confirmed, won, lost, claimed)
9+ * - outcome (optional) — filter by outcome (e.g. "yes" / "no")
10+ * - cursor (optional) — opaque token from the previous page's `nextCursor`
11+ * - limit (optional, default 20, max 100) — page size
12+ *
13+ * Response:
14+ * 200 { data: PredictionRow[], nextCursor: string | null }
15+ * 404 Market not found
16+ * 400 Validation error
17+ */
18+
19+ import { Router } from "express" ;
20+ import { and , eq , desc , lt , or } from "drizzle-orm" ;
21+ import { db } from "../../db/client" ;
22+ import { markets , predictions } from "../../db/schema" ;
23+ import { decodeCursor , encodeCursor , clampLimit } from "../../utils/cursor" ;
24+ import { getRequestId } from "../../lib/requestContext" ;
25+ import { logger } from "../../config/logger" ;
26+ import { RouteErrorFactory } from "../../errors" ;
27+ import { conditionalGet } from "../../middleware/etag" ;
28+ import { requestTimeout } from "../../middleware/timeout" ;
29+ import { listMarketPredictionsQuerySchema } from "../../validators/predictions" ;
30+ import type { Request , Response , NextFunction } from "express" ;
31+
32+ export const predictionsRouter = Router ( ) ;
33+
34+ // ── Timeout middleware ────────────────────────────────────────────────
35+ predictionsRouter . use ( requestTimeout ( 10000 ) ) ;
36+
37+ /**
38+ * GET /api/markets/:id/predictions
39+ */
40+ predictionsRouter . get (
41+ "/:id/predictions" ,
42+ async ( req : Request , res : Response , next : NextFunction ) : Promise < void > => {
43+ const reqId = getRequestId ( ) ;
44+ const marketId = req . params . id ;
45+
46+ try {
47+ // ── Validate market exists ──────────────────────────────────────
48+ const [ market ] = await db
49+ . select ( { id : markets . id } )
50+ . from ( markets )
51+ . where ( eq ( markets . id , marketId ) )
52+ . limit ( 1 ) ;
53+
54+ if ( ! market ) {
55+ throw RouteErrorFactory . notFound ( `Market with ID ${ marketId } not found` ) ;
56+ }
57+
58+ // ── Parse and validate query parameters ──────────────────────────
59+ const queryParse = listMarketPredictionsQuerySchema . safeParse ( req . query ) ;
60+ if ( ! queryParse . success ) {
61+ logger . warn (
62+ { reqId, marketId, issues : queryParse . error . issues } ,
63+ "market_predictions_list_invalid_query" ,
64+ ) ;
65+ res . status ( 400 ) . json ( {
66+ error : {
67+ code : "validation_error" ,
68+ message : queryParse . error . issues [ 0 ] ?. message ?? "invalid query parameters" ,
69+ requestId : reqId ,
70+ } ,
71+ } ) ;
72+ return ;
73+ }
74+
75+ const { status, outcome, cursor, limit : rawLimit } = queryParse . data ;
76+ const limit = clampLimit ( rawLimit ) ;
77+
78+ // ── Build WHERE conditions ──────────────────────────────────────
79+ const conditions = [ eq ( predictions . marketId , marketId ) ] ;
80+
81+ if ( status ) {
82+ conditions . push ( eq ( predictions . status , status ) ) ;
83+ }
84+
85+ if ( outcome ) {
86+ conditions . push ( eq ( predictions . outcome , outcome ) ) ;
87+ }
88+
89+ // ── Decode cursor ──────────────────────────────────────────────
90+ const cursorKey = decodeCursor ( cursor ) ;
91+ if ( cursorKey ) {
92+ const cursorTime = new Date ( cursorKey . sortValue ) ;
93+ conditions . push (
94+ or (
95+ lt ( predictions . createdAt , cursorTime ) ,
96+ and (
97+ eq ( predictions . createdAt , cursorTime ) ,
98+ lt ( predictions . id , cursorKey . id ) ,
99+ ) ,
100+ ) ! ,
101+ ) ;
102+ }
103+
104+ // ── Execute query ──────────────────────────────────────────────
105+ const rows = await db
106+ . select ( {
107+ id : predictions . id ,
108+ marketId : predictions . marketId ,
109+ userId : predictions . userId ,
110+ outcome : predictions . outcome ,
111+ amount : predictions . amount ,
112+ txHash : predictions . txHash ,
113+ status : predictions . status ,
114+ result : predictions . result ,
115+ createdAt : predictions . createdAt ,
116+ } )
117+ . from ( predictions )
118+ . where ( and ( ...conditions ) )
119+ . orderBy ( desc ( predictions . createdAt ) , desc ( predictions . id ) )
120+ . limit ( limit + 1 ) ;
121+
122+ const hasMore = rows . length > limit ;
123+ const data = rows . slice ( 0 , limit ) ;
124+
125+ // ── Mint next cursor ────────────────────────────────────────────
126+ const last = data [ data . length - 1 ] ;
127+ const nextCursor =
128+ hasMore && last
129+ ? encodeCursor ( {
130+ sortValue : last . createdAt . toISOString ( ) ,
131+ id : last . id ,
132+ } )
133+ : null ;
134+
135+ // ── Serialize response ──────────────────────────────────────────
136+ const payload = {
137+ data : data . map ( ( row ) => ( {
138+ ...row ,
139+ createdAt : row . createdAt . toISOString ( ) ,
140+ } ) ) ,
141+ nextCursor,
142+ } ;
143+
144+ // ── ETag handling ────────────────────────────────────────────────
145+ if ( conditionalGet ( payload , req , res ) ) {
146+ return ;
147+ }
148+
149+ logger . info (
150+ { reqId, marketId, count : data . length , hasNext : ! ! nextCursor } ,
151+ "market_predictions_list_success" ,
152+ ) ;
153+
154+ res . status ( 200 ) . json ( payload ) ;
155+ } catch ( err ) {
156+ if ( err instanceof Error && ( err as any ) . status === 404 ) {
157+ logger . warn ( { reqId, marketId } , "market_predictions_list_not_found" ) ;
158+ res . status ( 404 ) . json ( {
159+ error : {
160+ code : "not_found" ,
161+ message : `Market with ID ${ marketId } not found` ,
162+ requestId : reqId ,
163+ } ,
164+ } ) ;
165+ return ;
166+ }
167+ logger . error (
168+ { reqId, marketId, err } ,
169+ "market_predictions_list_failed" ,
170+ ) ;
171+ next ( err ) ;
172+ }
173+ } ,
174+ ) ;
0 commit comments