diff --git a/openapi.yaml b/openapi.yaml index 45cecd0d..3826510c 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -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: @@ -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: [] @@ -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? @@ -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: diff --git a/src/openapi/registry.ts b/src/openapi/registry.ts index 52bfa8f3..946a4920 100644 --- a/src/openapi/registry.ts +++ b/src/openapi/registry.ts @@ -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"); @@ -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({ @@ -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(), @@ -2503,7 +2505,7 @@ registry.registerPath({ examples: { authenticatedPredictionsPage: { value: { - data: [ + items: [ { id: "f47ac10b-58cc-4372-a567-0e02b2c3d479", marketId: "market_123", @@ -2517,7 +2519,7 @@ registry.registerPath({ resolutionTime: "2026-06-01T12:00:00.000Z", }, ], - nextCursor: "cursor_abc123", + next_cursor: "cursor_abc123", }, }, }, diff --git a/src/routes/predictions.ts b/src/routes/predictions.ts index 60f86ce4..c4fbdf30 100644 --- a/src/routes/predictions.ts +++ b/src/routes/predictions.ts @@ -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(); @@ -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: @@ -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( @@ -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( diff --git a/tests/integration/predictions.test.ts b/tests/integration/predictions.test.ts index 6f8b3a3b..0f2c7b42 100644 --- a/tests/integration/predictions.test.ts +++ b/tests/integration/predictions.test.ts @@ -149,8 +149,8 @@ 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?", @@ -158,7 +158,8 @@ describe("GET /api/predictions integration", () => { 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 () => { @@ -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 () => { @@ -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 }); }); }); @@ -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 () => { @@ -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 () => { @@ -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 () => { @@ -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" }); }); }); @@ -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 () => { @@ -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); }); }); @@ -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); }); }); });