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
418 changes: 359 additions & 59 deletions web/__tests__/contests.route.test.ts

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion web/app/api/auth/nonce/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { GET } from '../route';
import { NextRequest } from 'next/server';
import { getRedisClient, redis } from '@/lib/redis';
import { randomBytes } from 'crypto';

jest.mock('crypto', () => {
let counter = 0;
Expand Down
202 changes: 180 additions & 22 deletions web/app/api/contests/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { z } from 'zod';
import jwt from 'jsonwebtoken';
import { Pool } from 'pg';
import amqp from 'amqplib';
import { getRedisClient } from '@/lib/redis';

const DATABASE_URL = process.env.DATABASE_URL!;
const JWT_SECRET = process.env.JWT_SECRET!;
Expand All @@ -16,7 +17,7 @@ if (!DATABASE_URL || !JWT_SECRET || !RABBITMQ_URL) {
// PostgreSQL pool
export const pool = new Pool({ connectionString: DATABASE_URL });

// Zod schema for request body
// Zod schema for request body (POST)
const contestSchema = z.object({
sport: z.string(),
entryFee: z.number().nonnegative(),
Expand All @@ -26,15 +27,24 @@ const contestSchema = z.object({
maxPlayers: z.number().int().positive(),
});

// Zod schema for query params (GET)
const contestQuerySchema = z.object({
sport: z.string().optional(),
minFee: z.coerce.number().nonnegative().optional(),
maxFee: z.coerce.number().nonnegative().optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
});

type ContestInput = z.infer<typeof contestSchema>;
type ContestQuery = z.infer<typeof contestQuerySchema>;

// Helper: verify JWT and check admin
export function verifyAdmin(token: string | undefined) {
if (!token) {
throw new Error('No token provided');
}
try {
// JWT_SECRET is asserted as non-null
const payload = jwt.verify(token, JWT_SECRET) as any;
if (payload.role !== 'admin') {
throw new Error('Forbidden');
Expand All @@ -49,15 +59,148 @@ export function verifyAdmin(token: string | undefined) {
export let mqChannel: amqp.Channel;
export async function getMqChannel() {
if (mqChannel) return mqChannel;
// RABBITMQ_URL is asserted as non-null
const conn = await amqp.connect(RABBITMQ_URL);
const channel = await conn.createChannel();
await channel.assertExchange('contest.events', 'fanout', { durable: true });
mqChannel = channel;
return mqChannel;
}

// GET /contests - List contests with filters and pagination
export async function GET(req: NextRequest) {
let client;
try {
const { searchParams } = new URL(req.url);

// Build query params object
const rawQuery: Record<string, string> = {};
for (const key of ['sport', 'minFee', 'maxFee', 'page', 'limit'] as const) {
const v = searchParams.get(key);
if (v !== null) rawQuery[key] = v;
}

// Validate query params
let queryParams: ContestQuery;
try {
queryParams = contestQuerySchema.parse(rawQuery);
} catch (validationError) {
if (validationError instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid query parameters', details: validationError.errors },
{ status: 400 }
);
}
throw validationError;
}

const cacheKey = `contests:${JSON.stringify(queryParams)}`;

// Try Redis cache
try {
const redis = await getRedisClient();
const cached = await redis.get(cacheKey);
if (cached) {
const parsed = JSON.parse(cached) as {
contests: Array<Record<string, any>>;
total: number;
page: number;
limit: number;
totalPages: number;
};
// Convert startsAt back to Date objects
parsed.contests = parsed.contests.map((c) => ({
...c,
startsAt: new Date(c.startsAt),
}));
return NextResponse.json(parsed, { status: 200 });
}
} catch (cacheErr) {
console.warn('Redis cache error, proceeding without cache:', cacheErr);
}

// Build SQL query with filters
client = await pool.connect();
const conditions: string[] = [];
const values: any[] = [];
let paramIndex = 1;

if (queryParams.sport) {
conditions.push(`sport = $${paramIndex}`);
values.push(queryParams.sport);
paramIndex++;
}
if (queryParams.minFee !== undefined) {
conditions.push(`entry_fee >= $${paramIndex}`);
values.push(Number(queryParams.minFee));
paramIndex++;
}
if (queryParams.maxFee !== undefined) {
conditions.push(`entry_fee <= $${paramIndex}`);
values.push(Number(queryParams.maxFee));
paramIndex++;
}

const whereClause = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';

// Get total count
const countQuery = `SELECT COUNT(*) AS total FROM contests ${whereClause}`;
const countResult = await client.query(countQuery, values);
if (!countResult.rows || !countResult.rows[0]) {
throw new Error('Failed to retrieve total count');
}
const total = countResult.rows[0] ? parseInt(countResult.rows[0].total || '0', 10) : 0;

// Get paginated data
const offset = (queryParams.page - 1) * queryParams.limit;
const dataQuery = `
SELECT
id,
sport,
entry_fee AS "entryFee",
prize_pool AS "prizePool",
participants,
starts_at AS "startsAt"
FROM contests
${whereClause}
ORDER BY starts_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
const dataResult = await client.query(dataQuery, [...values, queryParams.limit, offset]);

const response = {
contests: dataResult.rows || [],
total,
page: queryParams.page,
limit: queryParams.limit,
totalPages: Math.ceil(total / queryParams.limit) || 1,
};

// Cache the result
try {
const redis = await getRedisClient();
await redis.setEx(cacheKey, 30, JSON.stringify(response));
} catch (cacheErr) {
console.warn('Failed to cache result:', cacheErr);
}

return NextResponse.json(response, { status: 200 });
} catch (err: any) {
console.error('GET /contests error:', err.message, err.stack);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
} finally {
if (client) {
try {
await client.release();
} catch (releaseErr) {
console.warn('Failed to release client:', releaseErr);
}
}
}
}

// POST /contests - Create new contest
export async function POST(req: NextRequest) {
let client;
try {
// Admin JWT from Authorization header
const authHeader = req.headers.get('authorization') || '';
Expand All @@ -67,25 +210,32 @@ export async function POST(req: NextRequest) {
const body = await req.json();
const parsed: ContestInput = contestSchema.parse(body);

const client = await pool.connect();
client = await pool.connect();
const result = await client.query(
`INSERT INTO contests (sport, entry_fee, starts_at, max_players)
VALUES ($1, $2, $3, $4)
RETURNING id, sport, entry_fee AS "entryFee", starts_at AS "startsAt", max_players AS "maxPlayers";`,
[parsed.sport, parsed.entryFee, parsed.startsAt, parsed.maxPlayers]
);
const contest = result.rows[0];

// Emit event
const channel = await getMqChannel();
const eventPayload = Buffer.from(JSON.stringify({ type: 'ContestCreated', data: contest }));
channel.publish('contest.events', '', eventPayload);

// Invalidate cache after creating new contest
try {
const result = await client.query(
`INSERT INTO contests (sport, entry_fee, starts_at, max_players)
VALUES ($1, $2, $3, $4)
RETURNING id, sport, entry_fee AS "entryFee", starts_at AS "startsAt", max_players AS "maxPlayers";`,
[parsed.sport, parsed.entryFee, parsed.startsAt, parsed.maxPlayers]
);
const contest = result.rows[0];

// Emit event
const channel = await getMqChannel();
const eventPayload = Buffer.from(JSON.stringify({ type: 'ContestCreated', data: contest }));
channel.publish('contest.events', '', eventPayload);

return NextResponse.json(contest, { status: 201 });
} finally {
client.release();
const redis = await getRedisClient();
const keys = await redis.keys('contests:*');
if (keys.length > 0) {
await redis.del(keys);
}
} catch (cacheError) {
console.warn('Failed to invalidate cache:', cacheError);
}

return NextResponse.json(contest, { status: 201 });
} catch (err: any) {
if (err.message === 'Forbidden') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
Expand All @@ -96,7 +246,15 @@ export async function POST(req: NextRequest) {
if (err.message === 'No token provided') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.error(err);
console.error('POST /contests error:', err);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
} finally {
if (client) {
try {
await client.release();
} catch (releaseErr) {
console.warn('Failed to release client:', releaseErr);
}
}
}
}
}
2 changes: 2 additions & 0 deletions web/app/components/MobileNav.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';

import { useState, useEffect, useCallback } from 'react';
Expand Down Expand Up @@ -33,6 +34,7 @@ export default function MobileNav() {
}, []);


// eslint-disable-next-line react-hooks/exhaustive-deps
const throttledDragEnd = useCallback(
(() => {
let lastCall = 0;
Expand Down
3 changes: 2 additions & 1 deletion web/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-require-imports */
const nextJest = require('next/jest');

const createJestConfig = nextJest({
Expand All @@ -12,7 +13,7 @@ const customJestConfig = {
'^@/lib/(.*)$': '<rootDir>/lib/$1',
},
moduleDirectories: ['node_modules', '<rootDir>/'],
testEnvironment: 'jest-environment-jsdom',
testEnvironment: 'jest-environment-node',
testPathIgnorePatterns: ['<rootDir>/node_modules/', '<rootDir>/.next/'],
};

Expand Down
1 change: 0 additions & 1 deletion web/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { type NextRequest, NextResponse } from "next/server"
import jwt from "jsonwebtoken"
import crypto from "crypto"
import { getRedisClient } from './redis';

export async function verifyNonce(walletAddress: string, nonce: string): Promise<boolean> {
Expand Down
Loading
Loading