Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -789,16 +789,19 @@ components:
PredictionsListResponse:
type: object
properties:
data:
items:
type: array
items:
$ref: '#/components/schemas/PredictionRow'
nextCursor:
next_cursor:
type: string
nullable: true
total:
type: integer
minimum: 0
required:
- data
- nextCursor
- items
- next_cursor
FollowResult:
type: object
properties:
Expand Down Expand Up @@ -3245,7 +3248,7 @@ paths:
summary: List the authenticated user’s predictions
description: >-
Returns a cursor-paginated list of predictions placed by the caller. Sort order is `createdAt DESC, id DESC`.
Pass the returned `nextCursor` as `?cursor=` to fetch the next page. `nextCursor` is `null` when no further
Pass the returned `next_cursor` as `?cursor=` to fetch the next page. `next_cursor` is `null` when no further
pages exist.
security:
- bearerAuth: []
Expand Down Expand Up @@ -3293,7 +3296,7 @@ paths:
examples:
authenticatedPredictionsPage:
value:
data:
items:
- id: f47ac10b-58cc-4372-a567-0e02b2c3d479
marketId: market_123
question: Will Bitcoin hit 100k in 2026?
Expand All @@ -3304,7 +3307,7 @@ paths:
result: 'yes'
createdAt: '2026-05-01T12:00:00.000Z'
resolutionTime: '2026-06-01T12:00:00.000Z'
nextCursor: cursor_abc123
next_cursor: cursor_abc123
'400':
description: Validation error — invalid query parameters
content:
Expand Down
16 changes: 9 additions & 7 deletions src/openapi/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2450,9 +2450,11 @@ const PredictionRow = z

const PredictionsListResponse = z
.object({
data: z.array(PredictionRow),
items: z.array(PredictionRow),
/** Opaque cursor for the next page, or null if this is the last page. */
nextCursor: z.string().nullable(),
next_cursor: z.string().nullable(),
/** Optional total count for clients that need it. */
total: z.number().int().nonnegative().optional(),
})
.openapi("PredictionsListResponse");

Expand All @@ -2477,8 +2479,8 @@ registry.registerPath({
description:
"Returns a cursor-paginated list of predictions placed by the caller. " +
"Sort order is `createdAt DESC, id DESC`. " +
"Pass the returned `nextCursor` as `?cursor=` to fetch the next page. " +
"`nextCursor` is `null` when no further pages exist.",
"Pass the returned `next_cursor` as `?cursor=` to fetch the next page. " +
"`next_cursor` is `null` when no further pages exist.",
security: [{ bearerAuth: [] }],
request: {
query: z.object({
Expand All @@ -2488,7 +2490,7 @@ registry.registerPath({
status: PredictionStatus.optional(),
/** Filter by chosen outcome value (e.g. "yes" / "no"). */
outcome: z.string().min(1).max(64).optional(),
/** Opaque cursor from the previous page\u2019s `nextCursor`. */
/** Opaque cursor from the previous page’s `next_cursor`. */
cursor: z.string().optional(),
/** Page size — default 20, max 100. */
limit: z.coerce.number().int().min(1).max(100).default(20).optional(),
Expand All @@ -2503,7 +2505,7 @@ registry.registerPath({
examples: {
authenticatedPredictionsPage: {
value: {
data: [
items: [
{
id: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
marketId: "market_123",
Expand All @@ -2517,7 +2519,7 @@ registry.registerPath({
resolutionTime: "2026-06-01T12:00:00.000Z",
},
],
nextCursor: "cursor_abc123",
next_cursor: "cursor_abc123",
},
},
},
Expand Down
20 changes: 8 additions & 12 deletions src/routes/predictions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,28 @@
* global idempotency middleware applied in `src/index.ts`.
*/

import { Router } from "express";
import { Router, Request, Response, NextFunction } from "express";
import { z } from "zod";
import { requireAuth } from "../middleware/requireAuth";
import { claimWinnings, ClaimError } from "../services/claimService";
import { logger } from "../config/logger";
import { getRequestId } from "../lib/requestContext";
import { Router, Request, Response, NextFunction } from "express";
import { requireAuth } from "../middleware/requireAuth";
import { createPerUserRateLimiter } from "../middleware/rateLimit";
import { getPredictionExplanation } from "../services/predictionExplainService";
import cancelRouter from "./predictions/cancel";
import { createShareRouter } from "./predictions/share";
import { predictionsHealthRouter } from "./predictions/health";
import { listPredictions } from "../repositories/predictionRepo";
import { logger } from "../config/logger";
import { getRequestId } from "../lib/requestContext";
import { clampLimit } from "../utils/cursor";
import {
predictionsListTotal,
predictionExplainTotal,
predictionsRequestDuration,
} from "../metrics/registry";
import { clampLimit } from "../utils/cursor";
import type { AuthenticatedRequest } from "../middleware/auth";
import { listPredictionsQuerySchema } from "../validators/predictions";
import { requestTimeout } from "../middleware/timeout";
import { conditionalGet } from "../middleware/etag";

export const predictionsRouter = Router();

Expand Down Expand Up @@ -154,15 +150,15 @@ predictionsRouter.post("/claim", async (req, res, next) => {
* - marketId (optional) — filter to a single market
* - status (optional) — one of: pending, confirmed, won, lost, claimed
* - outcome (optional) — e.g. "yes" / "no"
* - cursor (optional) — opaque token from the previous page's `nextCursor`
* - cursor (optional) — opaque token from the previous page's `next_cursor`
* - limit (optional, default 20, max 100) — page size
*
* Response:
* 200 { data: PredictionRow[], nextCursor: string | null }
* 200 { items: PredictionRow[], next_cursor: string | null, total?: number }
*
* Pagination:
* `nextCursor` is null on the last page. Pass it verbatim as `?cursor=` to
* fetch the next page. Cursors are versioned; a stale or tampered cursor
* `next_cursor` is null on the last page. Pass it verbatim as `?cursor=` to
* fetch the next page. Cursors are versioned; a stale or tampered cursor
* safely restarts from page 1 rather than returning a wrong offset.
*
* Errors:
Expand Down Expand Up @@ -221,7 +217,7 @@ predictionsRouter.get(
cursor,
});

const payload = { data: page.data, nextCursor: page.nextCursor };
const payload = { items: page.data, next_cursor: page.nextCursor };
if (conditionalGet(payload, req, res)) return;

logger.info(
Expand All @@ -240,7 +236,7 @@ predictionsRouter.get(
(Date.now() - startMs) / 1000,
);

res.json({ data: page.data, nextCursor: page.nextCursor });
res.json({ items: page.data, next_cursor: page.nextCursor });
} catch (err) {
predictionsListTotal.inc({ outcome: "error" });
predictionsRequestDuration.observe(
Expand Down
45 changes: 23 additions & 22 deletions tests/integration/predictions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,16 +149,17 @@ describe("GET /api/predictions integration", () => {
.set("Authorization", `Bearer ${tokenFor("GUSERONE")}`);

expect(res.status).toBe(200);
expect(res.body.data).toHaveLength(1);
expect(res.body.data[0]).toMatchObject({
expect(res.body.items).toHaveLength(1);
expect(res.body.items[0]).toMatchObject({
id: predictionId,
marketId: "market-1",
question: "Will it rain?",
outcome: "yes",
amount: "25",
status: "confirmed",
});
expect(res.body.nextCursor).toBeNull();
expect(res.body.next_cursor).toBeNull();
expect(res.body.total).toBeUndefined();
});

it("only returns predictions scoped to the authenticated user (never another user's)", async () => {
Expand All @@ -178,8 +179,8 @@ describe("GET /api/predictions integration", () => {
.set("Authorization", `Bearer ${tokenFor("GUSERA")}`);

expect(res.status).toBe(200);
expect(res.body.data).toHaveLength(1);
expect(res.body.data[0].outcome).toBe("yes");
expect(res.body.items).toHaveLength(1);
expect(res.body.items[0].outcome).toBe("yes");
});

it("returns an empty page (not an error) for a user with no predictions", async () => {
Expand All @@ -190,7 +191,7 @@ describe("GET /api/predictions integration", () => {
.set("Authorization", `Bearer ${tokenFor("GLONELYUSER")}`);

expect(res.status).toBe(200);
expect(res.body).toEqual({ data: [], nextCursor: null });
expect(res.body).toEqual({ items: [], next_cursor: null });
});
});

Expand Down Expand Up @@ -222,8 +223,8 @@ describe("GET /api/predictions integration", () => {
.set("Authorization", `Bearer ${tokenFor("GFILTERUSER")}`);

expect(res.status).toBe(200);
expect(res.body.data).toHaveLength(1);
expect(res.body.data[0].marketId).toBe("market-b");
expect(res.body.items).toHaveLength(1);
expect(res.body.items[0].marketId).toBe("market-b");
});

it("filters by status", async () => {
Expand All @@ -232,8 +233,8 @@ describe("GET /api/predictions integration", () => {
.set("Authorization", `Bearer ${tokenFor("GFILTERUSER")}`);

expect(res.status).toBe(200);
expect(res.body.data).toHaveLength(1);
expect(res.body.data[0].status).toBe("won");
expect(res.body.items).toHaveLength(1);
expect(res.body.items[0].status).toBe("won");
});

it("filters by outcome", async () => {
Expand All @@ -242,8 +243,8 @@ describe("GET /api/predictions integration", () => {
.set("Authorization", `Bearer ${tokenFor("GFILTERUSER")}`);

expect(res.status).toBe(200);
expect(res.body.data).toHaveLength(2);
res.body.data.forEach((p: { outcome: string }) => expect(p.outcome).toBe("yes"));
expect(res.body.items).toHaveLength(2);
res.body.items.forEach((p: { outcome: string }) => expect(p.outcome).toBe("yes"));
});

it("combines multiple filters", async () => {
Expand All @@ -252,8 +253,8 @@ describe("GET /api/predictions integration", () => {
.set("Authorization", `Bearer ${tokenFor("GFILTERUSER")}`);

expect(res.status).toBe(200);
expect(res.body.data).toHaveLength(1);
expect(res.body.data[0]).toMatchObject({ marketId: "market-a", outcome: "yes" });
expect(res.body.items).toHaveLength(1);
expect(res.body.items[0]).toMatchObject({ marketId: "market-a", outcome: "yes" });
});
});

Expand Down Expand Up @@ -292,18 +293,18 @@ describe("GET /api/predictions integration", () => {
.set("Authorization", `Bearer ${tokenFor("GPAGEUSER")}`);

expect(page1.status).toBe(200);
expect(page1.body.data).toHaveLength(2);
expect(page1.body.items).toHaveLength(2);
// Most-recent-first: the 2026-07-03 row (id `third`) should be first.
expect(page1.body.data[0].id).toBe(third);
expect(page1.body.nextCursor).toEqual(expect.any(String));
expect(page1.body.items[0].id).toBe(third);
expect(page1.body.next_cursor).toEqual(expect.any(String));

const page2 = await request(createPredictionsApp())
.get(`/api/predictions?limit=2&cursor=${encodeURIComponent(page1.body.nextCursor)}`)
.get(`/api/predictions?limit=2&cursor=${encodeURIComponent(page1.body.next_cursor)}`)
.set("Authorization", `Bearer ${tokenFor("GPAGEUSER")}`);

expect(page2.status).toBe(200);
expect(page2.body.data).toHaveLength(1);
expect(page2.body.nextCursor).toBeNull();
expect(page2.body.items).toHaveLength(1);
expect(page2.body.next_cursor).toBeNull();
});

it("silently restarts from page 1 for a garbage cursor instead of erroring", async () => {
Expand All @@ -321,7 +322,7 @@ describe("GET /api/predictions integration", () => {
.set("Authorization", `Bearer ${tokenFor("GBADCURSORUSER")}`);

expect(res.status).toBe(200);
expect(res.body.data).toHaveLength(1);
expect(res.body.items).toHaveLength(1);
});
});

Expand Down Expand Up @@ -409,7 +410,7 @@ describe("GET /api/predictions integration", () => {
.set("Authorization", `Bearer ${tokenFor("GDEFAULTLIMITUSER")}`);

expect(res.status).toBe(200);
expect(res.body.data).toHaveLength(1);
expect(res.body.items).toHaveLength(1);
});
});
});