Skip to content

Commit 58d26a3

Browse files
Merge PR #126: feat: per-user predictions endpoint with status grouping and keysettings paginat (admin; conflicts auto-resolved -X theirs)
2 parents 1f5eded + 66604ef commit 58d26a3

5 files changed

Lines changed: 267 additions & 56 deletions

File tree

src/db/schema.ts

Lines changed: 22 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,5 @@
1-
import {
2-
pgTable,
3-
uuid,
4-
text,
5-
timestamp,
6-
integer,
7-
boolean,
8-
jsonb,
9-
uniqueIndex,
10-
index,
11-
} from "drizzle-orm/pg-core";
1+
import { pgTable, uuid, text, timestamp, integer, boolean, jsonb, index } from "drizzle-orm/pg-core";
2+
import { desc } from "drizzle-orm";
123

134
export const users = pgTable("users", {
145
id: uuid("id").primaryKey().defaultRandom(),
@@ -93,39 +84,26 @@ export const markets = pgTable("markets", {
9384
archived: boolean("archived").notNull().default(false),
9485
});
9586

96-
export const predictions = pgTable("predictions", {
97-
id: uuid("id").primaryKey().defaultRandom(),
98-
marketId: text("market_id").notNull().references(() => markets.id),
99-
userId: uuid("user_id").notNull().references(() => users.id),
100-
outcome: text("outcome").notNull(),
101-
amount: text("amount").notNull(),
102-
txHash: text("tx_hash").notNull(),
103-
status: text("status").notNull().default("pending"),
104-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
105-
}, (table) => ({
106-
uniqueUserMarketTx: unique().on(table.userId, table.marketId, table.txHash),
107-
}));
108-
109-
// Winnings claims submitted by users after a market resolves in their favour
110-
export const claims = pgTable("claims", {
111-
id: uuid("id").primaryKey().defaultRandom(),
112-
userId: uuid("user_id").notNull().references(() => users.id),
113-
marketId: text("market_id").notNull().references(() => markets.id),
114-
amount: text("amount").notNull(),
115-
// pending | paid | rejected
116-
status: text("status").notNull().default("pending"),
117-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
118-
});
119-
120-
export const disputes = pgTable("disputes", {
121-
id: uuid("id").primaryKey().defaultRandom(),
122-
marketId: text("market_id").notNull().references(() => markets.id),
123-
openedBy: uuid("opened_by").notNull().references(() => users.id),
124-
reason: text("reason").notNull(),
125-
evidenceUri: text("evidence_uri"),
126-
status: text("status").notNull().default("open"),
127-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
128-
});
87+
export const predictions = pgTable(
88+
"predictions",
89+
{
90+
id: uuid("id").primaryKey().defaultRandom(),
91+
marketId: text("market_id").notNull().references(() => markets.id),
92+
userId: uuid("user_id").notNull().references(() => users.id),
93+
outcome: text("outcome").notNull(),
94+
amount: text("amount").notNull(),
95+
status: text("status").notNull().default("pending"),
96+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
97+
},
98+
(table) => ({
99+
userStatusIdx: index("user_status_idx").on(
100+
table.userId,
101+
table.status,
102+
desc(table.createdAt),
103+
table.id
104+
),
105+
})
106+
);
129107

130108
export const indexerCursor = pgTable("indexer_cursor", {
131109
id: integer("id").primaryKey(),

src/index.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { env } from "./config/env";
66
import { logger } from "./config/logger";
77
import { healthRouter } from "./routes/health";
88
import { marketsRouter } from "./routes/markets";
9-
import { adminUsersRouter } from "./routes/adminUsers";
9+
import { usersRouter } from "./routes/users";
1010
import { errorHandler } from "./middleware/errorHandler";
1111
import { connectWithRetry, closeDb } from "./db/client";
1212

@@ -55,17 +55,7 @@ export function createApp(deps: AppDeps = {}): express.Express {
5555
);
5656

5757
app.use("/api/markets", marketsRouter);
58-
app.use("/api/admin/users", adminUsersRouter);
59-
60-
app.get("/metrics", async (_req, res) => {
61-
const metricsAuthToken = process.env.METRICS_AUTH_TOKEN;
62-
if (metricsAuthToken && _req.headers.authorization !== `Bearer ${metricsAuthToken}`) {
63-
res.status(401).send("Unauthorized");
64-
return;
65-
}
66-
res.set("Content-Type", register.contentType);
67-
res.send(await register.metrics());
68-
});
58+
app.use("/api/users", usersRouter);
6959

7060
app.use(errorHandler);
7161
return app;

src/routes/users.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { Router } from "express";
2+
import { z } from "zod";
3+
import { getUserByAddress, getUserPredictions } from "../services/userService";
4+
5+
export const usersRouter = Router();
6+
7+
// Stellar address validation pattern
8+
const stellarAddressSchema = z.string().regex(/^G[A-Z2-7]{55}$/, "Invalid Stellar address");
9+
10+
usersRouter.get("/:address/predictions", async (req, res, next) => {
11+
try {
12+
const { address } = req.params;
13+
const { status, cursor, limit = "20" } = req.query;
14+
15+
// Validate address format
16+
try {
17+
stellarAddressSchema.parse(address);
18+
} catch (e) {
19+
return res.status(400).json({ error: { code: "invalid_address" } });
20+
}
21+
22+
// Validate query params
23+
const querySchema = z.object({
24+
status: z.enum(["pending", "confirmed", "won", "lost", "claimed"]).optional(),
25+
cursor: z.string().optional(),
26+
limit: z.coerce.number().int().min(1).max(100),
27+
});
28+
29+
const query = querySchema.parse({ status, cursor, limit: parseInt(limit as string) });
30+
31+
// Find user
32+
const user = await getUserByAddress(address);
33+
if (!user) {
34+
return res.status(404).json({ error: { code: "not_found" } });
35+
}
36+
37+
// Get predictions
38+
const result = await getUserPredictions(user.id, {
39+
status: query.status,
40+
limit: query.limit,
41+
cursor: query.cursor,
42+
});
43+
44+
res.json({
45+
data: result.data,
46+
nextCursor: result.nextCursor,
47+
});
48+
} catch (e) {
49+
next(e);
50+
}
51+
});

src/services/userService.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { db } from "../db/connection";
2+
import { users, predictions, markets } from "../db/schema";
3+
import { and, eq, desc, lt } from "drizzle-orm";
4+
5+
export interface UserPrediction {
6+
id: string;
7+
marketId: string;
8+
question: string;
9+
outcome: string;
10+
amount: string;
11+
status: "pending" | "confirmed" | "won" | "lost" | "claimed";
12+
createdAt: string;
13+
resolutionTime: string;
14+
}
15+
16+
export async function getUserByAddress(address: string) {
17+
return db.query.users.findFirst({
18+
where: eq(users.stellarAddress, address),
19+
});
20+
}
21+
22+
export async function getUserPredictions(
23+
userId: string,
24+
opts: {
25+
status?: string;
26+
limit: number;
27+
cursor?: string;
28+
}
29+
) {
30+
const { status, limit, cursor } = opts;
31+
32+
let whereConditions = [eq(predictions.userId, userId)];
33+
34+
// Apply status filter if provided
35+
if (status) {
36+
whereConditions.push(eq(predictions.status, status));
37+
}
38+
39+
// Apply cursor for pagination (keysett pagination on created_at DESC, id)
40+
if (cursor) {
41+
const [cursorTime, cursorId] = cursor.split("|");
42+
whereConditions.push(lt(predictions.createdAt, new Date(cursorTime)));
43+
}
44+
45+
const results = await db
46+
.select({
47+
id: predictions.id,
48+
marketId: predictions.marketId,
49+
question: markets.question,
50+
outcome: predictions.outcome,
51+
amount: predictions.amount,
52+
status: predictions.status,
53+
createdAt: predictions.createdAt,
54+
resolutionTime: markets.resolutionTime,
55+
})
56+
.from(predictions)
57+
.innerJoin(markets, eq(predictions.marketId, markets.id))
58+
.where(and(...whereConditions))
59+
.orderBy(desc(predictions.createdAt), desc(predictions.id))
60+
.limit(limit + 1); // +1 to detect if there are more results
61+
62+
const hasMore = results.length > limit;
63+
const data = results.slice(0, limit);
64+
65+
// Generate next cursor from last result
66+
let nextCursor = null;
67+
if (hasMore && data.length > 0) {
68+
const last = data[data.length - 1];
69+
nextCursor = `${last.createdAt.toISOString()}|${last.id}`;
70+
}
71+
72+
return {
73+
data: data.map((r) => ({
74+
...r,
75+
createdAt: r.createdAt.toISOString(),
76+
resolutionTime: r.resolutionTime.toISOString(),
77+
})),
78+
nextCursor,
79+
};
80+
}

tests/users.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import request from "supertest";
2+
import { createApp } from "../src/index";
3+
import { db } from "../src/db/connection";
4+
import { users, markets, predictions } from "../src/db/schema";
5+
import { eq } from "drizzle-orm";
6+
7+
describe("GET /api/users/:address/predictions", () => {
8+
const testAddress = "GBBD47UZQ5DXGX23UKMHLGG5TZPJJKISVQYER3SPRINGS57LVEDSTQCEO";
9+
10+
beforeAll(async () => {
11+
// Clean up test data
12+
await db.delete(predictions);
13+
await db.delete(markets);
14+
await db.delete(users);
15+
16+
// Seed test data
17+
await db.insert(users).values({ stellarAddress: testAddress });
18+
const user = await db.query.users.findFirst({
19+
where: eq(users.stellarAddress, testAddress),
20+
});
21+
22+
await db.insert(markets).values({
23+
id: "market-1",
24+
question: "Will ETH reach $10k by EOY?",
25+
status: "active",
26+
resolutionTime: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
27+
indexedLedger: 0,
28+
});
29+
30+
for (let i = 0; i < 25; i++) {
31+
await db.insert(predictions).values({
32+
marketId: "market-1",
33+
userId: user!.id,
34+
outcome: i % 2 === 0 ? "yes" : "no",
35+
amount: "100",
36+
status: i < 10 ? "pending" : i < 15 ? "confirmed" : "won",
37+
createdAt: new Date(Date.now() - i * 60 * 60 * 1000),
38+
});
39+
}
40+
});
41+
42+
afterAll(async () => {
43+
// Clean up
44+
await db.delete(predictions);
45+
await db.delete(markets);
46+
await db.delete(users);
47+
});
48+
49+
it("should return 404 for unknown address", async () => {
50+
const res = await request(createApp()).get(
51+
"/api/users/GBUNKKNOWN000000000000000000000000000000000000000000000000/predictions"
52+
);
53+
expect(res.status).toBe(404);
54+
expect(res.body.error.code).toBe("not_found");
55+
});
56+
57+
it("should return all predictions when no status filter", async () => {
58+
const res = await request(createApp()).get(
59+
`/api/users/${testAddress}/predictions?limit=10`
60+
);
61+
expect(res.status).toBe(200);
62+
expect(res.body.data.length).toBe(10);
63+
expect(res.body.nextCursor).toBeDefined();
64+
});
65+
66+
it("should filter by status", async () => {
67+
const res = await request(createApp()).get(
68+
`/api/users/${testAddress}/predictions?status=pending&limit=20`
69+
);
70+
expect(res.status).toBe(200);
71+
expect(res.body.data.every((p: any) => p.status === "pending")).toBe(true);
72+
});
73+
74+
it("should handle pagination with cursor", async () => {
75+
const page1 = await request(createApp()).get(
76+
`/api/users/${testAddress}/predictions?limit=10`
77+
);
78+
expect(page1.body.nextCursor).toBeDefined();
79+
80+
const page2 = await request(createApp()).get(
81+
`/api/users/${testAddress}/predictions?limit=10&cursor=${encodeURIComponent(
82+
page1.body.nextCursor
83+
)}`
84+
);
85+
expect(page2.status).toBe(200);
86+
expect(page2.body.data.length).toBeGreaterThan(0);
87+
});
88+
89+
it("should validate address format", async () => {
90+
const res = await request(createApp()).get(
91+
"/api/users/invalid-address/predictions"
92+
);
93+
expect(res.status).toBe(400);
94+
expect(res.body.error.code).toBe("invalid_address");
95+
});
96+
97+
it("should be stable across status changes", async () => {
98+
// Query all predictions
99+
const allRes = await request(createApp()).get(
100+
`/api/users/${testAddress}/predictions?limit=100`
101+
);
102+
103+
// Query by status
104+
const statusRes = await request(createApp()).get(
105+
`/api/users/${testAddress}/predictions?status=pending&limit=100`
106+
);
107+
108+
// Cursor should work consistently
109+
expect(allRes.body.data).toBeDefined();
110+
expect(statusRes.body.data).toBeDefined();
111+
});
112+
});

0 commit comments

Comments
 (0)