From b6fca8de6cd767c03c5012389095051b86e991a2 Mon Sep 17 00:00:00 2001 From: JackAttack-365 <142643773+jackattack-4@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:22:59 -0700 Subject: [PATCH] add tournament catalog module --- README.md | 1 + src/app/create-app.ts | 9 + src/app/dependencies.ts | 5 + src/modules/accounts/accounts.test.ts | 8 + src/modules/api-keys/api-keys.test.ts | 8 + src/modules/tournaments/index.ts | 3 + .../tournaments/tournaments.contracts.ts | 29 +++ .../tournaments/tournaments.repository.ts | 89 +++++++++ src/modules/tournaments/tournaments.routes.ts | 77 ++++++++ .../tournaments/tournaments.service.ts | 21 ++ src/modules/tournaments/tournaments.test.ts | 184 ++++++++++++++++++ 11 files changed, 434 insertions(+) create mode 100644 src/modules/tournaments/index.ts create mode 100644 src/modules/tournaments/tournaments.contracts.ts create mode 100644 src/modules/tournaments/tournaments.repository.ts create mode 100644 src/modules/tournaments/tournaments.routes.ts create mode 100644 src/modules/tournaments/tournaments.service.ts create mode 100644 src/modules/tournaments/tournaments.test.ts diff --git a/README.md b/README.md index 6bca446..b0b053d 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ The health endpoint is available at `GET /v2/health`. - `GET /v2/accounts/me` and `DELETE /v2/accounts/me` - `GET /v2/api-keys` and `POST /v2/api-keys` - `PATCH /v2/api-keys/{uuid}` and `DELETE /v2/api-keys/{uuid}` +- `GET /v2/tournaments` and `GET /v2/tournaments/{key}/teams` - `GET /v2/openapi.json` Dashboard routes require an Auth0 bearer token. API-key management additionally requires diff --git a/src/app/create-app.ts b/src/app/create-app.ts index ac3c492..2093e0a 100644 --- a/src/app/create-app.ts +++ b/src/app/create-app.ts @@ -3,6 +3,7 @@ import type { AppEnvironment } from './context'; import type { AppDependencies } from './dependencies'; import { createAccountsRouter } from '../modules/accounts'; import { createApiKeysRouter } from '../modules/api-keys'; +import { createTournamentsRouter } from '../modules/tournaments'; import { handleErrors } from '../platform/http/errors'; import { requestId } from '../platform/http/request-id'; @@ -30,6 +31,14 @@ export function createApp(dependencies: AppDependencies) { }) ); + app.route( + '/v2/tournaments', + createTournamentsRouter({ + tournaments: dependencies.tournaments, + authenticator: dependencies.authenticator, + }) + ); + app.openAPIRegistry.registerComponent('securitySchemes', 'DashboardAuth', { type: 'http', scheme: 'bearer', diff --git a/src/app/dependencies.ts b/src/app/dependencies.ts index 0407a8f..7448499 100644 --- a/src/app/dependencies.ts +++ b/src/app/dependencies.ts @@ -1,15 +1,18 @@ import { createAuth0Authenticator } from '../integrations/auth0/auth0-authenticator'; import { createAccountsRepository, createAccountsService } from '../modules/accounts'; import { createApiKeysRepository, createApiKeysService } from '../modules/api-keys'; +import { createTournamentsRepository, createTournamentsService } from '../modules/tournaments'; import { loadEnvironment } from '../platform/config/environment'; import { createDatabase } from '../platform/database/client'; import type { Authenticator } from '../platform/auth/types'; import type { AccountsService } from '../modules/accounts'; import type { ApiKeysService } from '../modules/api-keys'; +import type { TournamentsService } from '../modules/tournaments'; export type AppDependencies = { accounts: AccountsService; apiKeys: ApiKeysService; + tournaments: TournamentsService; authenticator: Authenticator; apiVersion: string; }; @@ -19,10 +22,12 @@ export function createProductionDependencies(): AppDependencies { const database = createDatabase(environment); const accountsRepository = createAccountsRepository(database); const apiKeysRepository = createApiKeysRepository(database); + const tournamentsRepository = createTournamentsRepository(database); return { accounts: createAccountsService(accountsRepository), apiKeys: createApiKeysService(apiKeysRepository), + tournaments: createTournamentsService(tournamentsRepository), authenticator: createAuth0Authenticator( environment.AUTH0_DOMAIN, environment.AUTH0_AUDIENCE, diff --git a/src/modules/accounts/accounts.test.ts b/src/modules/accounts/accounts.test.ts index ea48636..96e63d5 100644 --- a/src/modules/accounts/accounts.test.ts +++ b/src/modules/accounts/accounts.test.ts @@ -52,6 +52,14 @@ describe('accounts module', () => { throw new Error('Not used by this test'); }, }, + tournaments: { + async list() { + return { tournaments: [], count: 0 }; + }, + async listTeams() { + return []; + }, + }, authenticator: { async authenticate(token) { return token === 'valid-token' diff --git a/src/modules/api-keys/api-keys.test.ts b/src/modules/api-keys/api-keys.test.ts index c236e0b..3a54d79 100644 --- a/src/modules/api-keys/api-keys.test.ts +++ b/src/modules/api-keys/api-keys.test.ts @@ -134,6 +134,14 @@ describe('api keys module', () => { throw new Error('Not used by this test'); }, }, + tournaments: { + async list() { + return { tournaments: [], count: 0 }; + }, + async listTeams() { + return []; + }, + }, authenticator: { async authenticate(token) { const owner = [analyst, teammate, lead, outsider, unverified].find( diff --git a/src/modules/tournaments/index.ts b/src/modules/tournaments/index.ts new file mode 100644 index 0000000..34b8571 --- /dev/null +++ b/src/modules/tournaments/index.ts @@ -0,0 +1,3 @@ +export * from './tournaments.repository'; +export * from './tournaments.routes'; +export * from './tournaments.service'; diff --git a/src/modules/tournaments/tournaments.contracts.ts b/src/modules/tournaments/tournaments.contracts.ts new file mode 100644 index 0000000..297cbf9 --- /dev/null +++ b/src/modules/tournaments/tournaments.contracts.ts @@ -0,0 +1,29 @@ +import { z } from '@hono/zod-openapi'; +import { TeamSchema, TournamentSchema } from '../../platform/database/schemas'; + +export const TournamentSummarySchema = TournamentSchema.extend({ + isParticipant: z.boolean(), +}).openapi('TournamentSummary'); + +export const TournamentListQuerySchema = z + .object({ + filter: z.string().trim().max(100).optional(), + limit: z.coerce.number().int().min(1).max(100).default(50), + offset: z.coerce.number().int().min(0).default(0), + }) + .openapi('TournamentListQuery'); + +export const TournamentListResponseSchema = z + .object({ + tournaments: z.array(TournamentSummarySchema), + count: z.number().int().min(0), + }) + .openapi('TournamentListResponse'); + +export const TournamentPathSchema = TournamentSchema.pick({ key: true }) + .extend({ key: z.string().trim().min(1).max(50) }) + .openapi('TournamentPath'); + +export const TournamentTeamsResponseSchema = z + .object({ teams: z.array(TeamSchema) }) + .openapi('TournamentTeamsResponse'); diff --git a/src/modules/tournaments/tournaments.repository.ts b/src/modules/tournaments/tournaments.repository.ts new file mode 100644 index 0000000..f82c441 --- /dev/null +++ b/src/modules/tournaments/tournaments.repository.ts @@ -0,0 +1,89 @@ +import { asc, count, desc, eq, ilike, or, sql } from 'drizzle-orm'; +import type { Database } from '../../platform/database/client'; +import { teamMatchData, teams, tournaments, users } from '../../platform/database/schema'; + +export type Tournament = typeof tournaments.$inferSelect; +export type Team = typeof teams.$inferSelect; +export type TournamentSummary = Tournament & { isParticipant: boolean }; + +export type TournamentListOptions = { + filter?: string; + limit: number; + offset: number; + teamNumber: number | null; +}; + +export interface TournamentsRepository { + findUserTeamNumber(userId: string): Promise; + list( + options: TournamentListOptions + ): Promise<{ tournaments: TournamentSummary[]; count: number }>; + exists(key: string): Promise; + listTeams(key: string): Promise; +} + +export function createTournamentsRepository(database: Database): TournamentsRepository { + return { + async findUserTeamNumber(userId) { + const [account] = await database + .select({ teamNumber: users.teamNumber }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + return account?.teamNumber; + }, + + async list({ filter, limit, offset, teamNumber }) { + const where = filter + ? or(ilike(tournaments.key, `%${filter}%`), ilike(tournaments.name, `%${filter}%`)) + : undefined; + const isParticipant = + teamNumber === null + ? sql`false` + : sql`exists ( + select 1 + from ${teamMatchData} + where ${teamMatchData.tournamentKey} = ${tournaments.key} + and ${teamMatchData.teamNumber} = ${teamNumber} + )`; + + const [rows, [total]] = await Promise.all([ + database + .select({ + key: tournaments.key, + name: tournaments.name, + location: tournaments.location, + date: tournaments.date, + latestFetchETag: tournaments.latestFetchETag, + isParticipant, + }) + .from(tournaments) + .where(where) + .orderBy(desc(isParticipant), asc(tournaments.date), asc(tournaments.key)) + .limit(limit) + .offset(offset), + database.select({ value: count() }).from(tournaments).where(where), + ]); + + return { tournaments: rows, count: total.value }; + }, + + async exists(key) { + const [tournament] = await database + .select({ key: tournaments.key }) + .from(tournaments) + .where(eq(tournaments.key, key)) + .limit(1); + return tournament !== undefined; + }, + + listTeams(key) { + return database + .selectDistinct({ number: teams.number, name: teams.name }) + .from(teamMatchData) + .innerJoin(teams, eq(teamMatchData.teamNumber, teams.number)) + .where(eq(teamMatchData.tournamentKey, key)) + .orderBy(asc(teams.number)); + }, + }; +} diff --git a/src/modules/tournaments/tournaments.routes.ts b/src/modules/tournaments/tournaments.routes.ts new file mode 100644 index 0000000..3f97195 --- /dev/null +++ b/src/modules/tournaments/tournaments.routes.ts @@ -0,0 +1,77 @@ +import { createRoute, OpenAPIHono } from '@hono/zod-openapi'; +import type { AppEnvironment } from '../../app/context'; +import { dashboardAuth } from '../../platform/auth/dashboard-auth'; +import type { Authenticator } from '../../platform/auth/types'; +import { ErrorResponseSchema } from '../../platform/http/contracts'; +import { BadRequest } from '../../platform/http/errors'; +import { + TournamentListQuerySchema, + TournamentListResponseSchema, + TournamentPathSchema, + TournamentTeamsResponseSchema, +} from './tournaments.contracts'; +import type { TournamentsService } from './tournaments.service'; + +type TournamentsRouteDependencies = { + tournaments: TournamentsService; + authenticator: Authenticator; +}; + +const errorResponse = { + content: { 'application/json': { schema: ErrorResponseSchema } }, +} as const; + +const listTournamentsRoute = createRoute({ + method: 'get', + path: '/', + security: [{ DashboardAuth: [] }], + request: { query: TournamentListQuerySchema }, + responses: { + 200: { + description: 'Filtered tournament catalog with participation status', + content: { 'application/json': { schema: TournamentListResponseSchema } }, + }, + 400: { description: 'Invalid query', ...errorResponse }, + 401: { description: 'Authentication required', ...errorResponse }, + 404: { description: 'Account not found', ...errorResponse }, + }, +}); + +const listTournamentTeamsRoute = createRoute({ + method: 'get', + path: '/{key}/teams', + security: [{ DashboardAuth: [] }], + request: { params: TournamentPathSchema }, + responses: { + 200: { + description: 'Teams participating in the tournament', + content: { 'application/json': { schema: TournamentTeamsResponseSchema } }, + }, + 400: { description: 'Invalid tournament key', ...errorResponse }, + 401: { description: 'Authentication required', ...errorResponse }, + 404: { description: 'Tournament not found', ...errorResponse }, + }, +}); + +export function createTournamentsRouter(dependencies: TournamentsRouteDependencies) { + const router = new OpenAPIHono({ + defaultHook(result) { + if (!result.success) throw new BadRequest(); + }, + }); + router.use('*', dashboardAuth(dependencies.authenticator)); + + router.openapi(listTournamentsRoute, async (context) => { + const query = context.req.valid('query'); + const result = await dependencies.tournaments.list(context.get('auth').userId, query); + return context.json(result, 200); + }); + + router.openapi(listTournamentTeamsRoute, async (context) => { + const { key } = context.req.valid('param'); + const teams = await dependencies.tournaments.listTeams(key); + return context.json({ teams }, 200); + }); + + return router; +} diff --git a/src/modules/tournaments/tournaments.service.ts b/src/modules/tournaments/tournaments.service.ts new file mode 100644 index 0000000..f7464c3 --- /dev/null +++ b/src/modules/tournaments/tournaments.service.ts @@ -0,0 +1,21 @@ +import { NotFound } from '../../platform/http/errors'; +import type { TournamentListOptions, TournamentsRepository } from './tournaments.repository'; + +type ListOptions = Pick; + +export function createTournamentsService(repository: TournamentsRepository) { + return { + async list(userId: string, options: ListOptions) { + const teamNumber = await repository.findUserTeamNumber(userId); + if (teamNumber === undefined) throw new NotFound('Account not found'); + return repository.list({ ...options, teamNumber }); + }, + + async listTeams(key: string) { + if (!(await repository.exists(key))) throw new NotFound('Tournament not found'); + return repository.listTeams(key); + }, + }; +} + +export type TournamentsService = ReturnType; diff --git a/src/modules/tournaments/tournaments.test.ts b/src/modules/tournaments/tournaments.test.ts new file mode 100644 index 0000000..0bbb681 --- /dev/null +++ b/src/modules/tournaments/tournaments.test.ts @@ -0,0 +1,184 @@ +import { beforeEach, describe, expect, it } from 'bun:test'; +import { createApp } from '../../app/create-app'; +import type { AppDependencies } from '../../app/dependencies'; +import type { + Team, + Tournament, + TournamentListOptions, + TournamentsRepository, +} from './tournaments.repository'; +import { createTournamentsService } from './tournaments.service'; + +const tournamentRows: Tournament[] = [ + { + key: '2026alpha', + name: 'Alpha Regional', + location: 'Oakland, CA', + date: '2026-03-01', + latestFetchETag: null, + }, + { + key: '2026beta', + name: 'Beta Regional', + location: 'San Jose, CA', + date: '2026-02-01', + latestFetchETag: null, + }, + { + key: '2026champ', + name: 'Championship', + location: null, + date: '2026-04-01', + latestFetchETag: 'etag', + }, +]; + +const tournamentTeams = new Map([ + [ + '2026alpha', + [ + { number: 8033, name: 'Highlander Robotics' }, + { number: 254, name: 'The Cheesy Poofs' }, + ], + ], +]); + +function createMemoryRepository(): TournamentsRepository { + return { + async findUserTeamNumber(userId) { + if (userId === 'missing') return undefined; + return userId === 'no-team' ? null : 8033; + }, + async list(options: TournamentListOptions) { + const filter = options.filter?.toLowerCase(); + const matching = tournamentRows.filter( + ({ key, name }) => + !filter || key.toLowerCase().includes(filter) || name.toLowerCase().includes(filter) + ); + const summaries = matching + .map((tournament) => ({ + ...tournament, + isParticipant: options.teamNumber === 8033 && tournament.key === '2026alpha', + })) + .sort( + (left, right) => + Number(right.isParticipant) - Number(left.isParticipant) || + (left.date ?? '').localeCompare(right.date ?? '') || + left.key.localeCompare(right.key) + ); + return { + tournaments: summaries.slice(options.offset, options.offset + options.limit), + count: summaries.length, + }; + }, + async exists(key) { + return tournamentRows.some((tournament) => tournament.key === key); + }, + async listTeams(key) { + return [...(tournamentTeams.get(key) ?? [])].sort((a, b) => a.number - b.number); + }, + }; +} + +describe('tournaments module', () => { + let dependencies: AppDependencies; + + beforeEach(() => { + dependencies = { + tournaments: createTournamentsService(createMemoryRepository()), + accounts: { + async getRequired() { + throw new Error('Not used by this test'); + }, + async delete() { + throw new Error('Not used by this test'); + }, + }, + apiKeys: { + async list() { + return []; + }, + async create() { + throw new Error('Not used by this test'); + }, + async rename() { + throw new Error('Not used by this test'); + }, + async revoke() { + throw new Error('Not used by this test'); + }, + }, + authenticator: { + async authenticate(token) { + return token === 'valid-token' + ? { userId: 'analyst', role: 'ANALYST', tokenType: 'jwt' } + : null; + }, + }, + apiVersion: 'test', + }; + }); + + it('lists participating tournaments first and reports the filtered count', async () => { + const response = await createApp(dependencies).request('/v2/tournaments', { + headers: { authorization: 'Bearer valid-token' }, + }); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + tournaments: { key: string; isParticipant: boolean }[]; + count: number; + }; + expect(body.count).toBe(3); + expect(body.tournaments.map(({ key }) => key)).toEqual(['2026alpha', '2026beta', '2026champ']); + expect(body.tournaments[0].isParticipant).toBe(true); + }); + + it('filters before applying bounded pagination', async () => { + const response = await createApp(dependencies).request( + '/v2/tournaments?filter=regional&limit=1&offset=1', + { headers: { authorization: 'Bearer valid-token' } } + ); + const body = (await response.json()) as { tournaments: { key: string }[]; count: number }; + expect(body.count).toBe(2); + expect(body.tournaments.map(({ key }) => key)).toEqual(['2026beta']); + }); + + it('returns unique tournament teams in team-number order', async () => { + const response = await createApp(dependencies).request('/v2/tournaments/2026alpha/teams', { + headers: { authorization: 'Bearer valid-token' }, + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + teams: [ + { number: 254, name: 'The Cheesy Poofs' }, + { number: 8033, name: 'Highlander Robotics' }, + ], + }); + }); + + it('returns not found for an unknown tournament', async () => { + const response = await createApp(dependencies).request('/v2/tournaments/unknown/teams', { + headers: { authorization: 'Bearer valid-token' }, + }); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Tournament not found' }, + }); + }); + + it('standardizes validation failures and documents both routes', async () => { + const app = createApp(dependencies); + const invalid = await app.request('/v2/tournaments?limit=101', { + headers: { authorization: 'Bearer valid-token' }, + }); + expect(invalid.status).toBe(400); + expect(await invalid.json()).toEqual({ + error: { code: 'BAD_REQUEST', message: 'Invalid request' }, + }); + + const specification = JSON.stringify(await (await app.request('/v2/openapi.json')).json()); + expect(specification).toContain('/v2/tournaments'); + expect(specification).toContain('/v2/tournaments/{key}/teams'); + }); +});