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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/app/create-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions src/app/dependencies.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Expand All @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions src/modules/accounts/accounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
8 changes: 8 additions & 0 deletions src/modules/api-keys/api-keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions src/modules/tournaments/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './tournaments.repository';
export * from './tournaments.routes';
export * from './tournaments.service';
29 changes: 29 additions & 0 deletions src/modules/tournaments/tournaments.contracts.ts
Original file line number Diff line number Diff line change
@@ -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');
89 changes: 89 additions & 0 deletions src/modules/tournaments/tournaments.repository.ts
Original file line number Diff line number Diff line change
@@ -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<number | null | undefined>;
list(
options: TournamentListOptions
): Promise<{ tournaments: TournamentSummary[]; count: number }>;
exists(key: string): Promise<boolean>;
listTeams(key: string): Promise<Team[]>;
}

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<boolean>`false`
: sql<boolean>`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));
},
};
}
77 changes: 77 additions & 0 deletions src/modules/tournaments/tournaments.routes.ts
Original file line number Diff line number Diff line change
@@ -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<AppEnvironment>({
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;
}
21 changes: 21 additions & 0 deletions src/modules/tournaments/tournaments.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { NotFound } from '../../platform/http/errors';
import type { TournamentListOptions, TournamentsRepository } from './tournaments.repository';

type ListOptions = Pick<TournamentListOptions, 'filter' | 'limit' | 'offset'>;

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<typeof createTournamentsService>;
Loading