Skip to content

Commit 6d910ea

Browse files
Merge pull request #753 from smartalee/feature/market-predictions-list
feat: add GET /api/markets/:id/predictions with cursor pagination
2 parents 62c03bb + f2668e7 commit 6d910ea

3 files changed

Lines changed: 203 additions & 0 deletions

File tree

src/routes/markets/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ marketsRouter.use("/:id/prediction-count", predictionCountRouter);
6161
marketsRouter.use("/:id/watchers", watchersRouter);
6262
marketsRouter.use("/:id/audit", marketAuditRouter);
6363
marketsRouter.use("/:id/disputes", disputesRouter);
64+
marketsRouter.use("/", predictionsRouter);
6465

6566
marketsRouter.get("/search", trackMarketsMetrics("search"), async (req, res, next) => {
6667
const reqId = String((req as { id?: unknown }).id ?? "anon");

src/routes/markets/predictions.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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+
);

src/validators/predictions.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,31 @@ export const predictionIdParamSchema = z
4848
.strict();
4949

5050
export type PredictionIdParam = z.infer<typeof predictionIdParamSchema>;
51+
52+
/**
53+
* Schema for GET /api/markets/:id/predictions query parameters.
54+
*/
55+
export const listMarketPredictionsQuerySchema = z
56+
.object({
57+
status: z
58+
.enum(["pending", "confirmed", "won", "lost", "claimed"], {
59+
message: "status must be one of: pending, confirmed, won, lost, claimed",
60+
})
61+
.optional(),
62+
outcome: z
63+
.string({ invalid_type_error: "outcome must be a string" })
64+
.trim()
65+
.min(1, "outcome must be a non-empty string")
66+
.max(64, "outcome must be at most 64 characters")
67+
.optional(),
68+
cursor: z.string({ invalid_type_error: "cursor must be a string" }).optional(),
69+
limit: z.coerce
70+
.number({ invalid_type_error: "limit must be a number" })
71+
.int("limit must be an integer")
72+
.min(1, "limit must be between 1 and 100")
73+
.max(100, "limit must be between 1 and 100")
74+
.default(20),
75+
})
76+
.strict();
77+
78+
export type ListMarketPredictionsQuery = z.infer<typeof listMarketPredictionsQuerySchema>;

0 commit comments

Comments
 (0)