From cebcb28d4273aa7ce2074d5e15b44e6a972bf876 Mon Sep 17 00:00:00 2001 From: Diango Gavidia Date: Thu, 20 Nov 2025 17:59:35 -0400 Subject: [PATCH 01/12] feat: update docker-compose and init.sql for PostgreSQL configuration --- docker-compose.yaml | 5 ++--- init.sql | 6 ++++++ 2 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 init.sql diff --git a/docker-compose.yaml b/docker-compose.yaml index e651051..f80bf9c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -8,15 +8,14 @@ services: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_HOST_AUTH_METHOD: trust ports: - - "5432:5432" + - "${POSTGRES_PORT}:5432" volumes: - postgres_data:/var/lib/postgresql/data - ./init.sql:/docker-entrypoint-initdb.d/init.sql restart: unless-stopped healthcheck: - test: [ "CMD-SHELL", "pg_isready -U evolution -d evolution" ] + test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}" ] interval: 10s timeout: 5s retries: 5 diff --git a/init.sql b/init.sql new file mode 100644 index 0000000..f65de39 --- /dev/null +++ b/init.sql @@ -0,0 +1,6 @@ +-- Habilitar extensión para UUIDs +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Configuraciones adicionales si son necesarias +-- Por ejemplo, configurar timezone +SET timezone = 'UTC'; \ No newline at end of file From 15ecbd819a8cd8e7bb81889bac155a4ee4b2b229 Mon Sep 17 00:00:00 2001 From: Diango Gavidia Date: Thu, 20 Nov 2025 18:10:50 -0400 Subject: [PATCH 02/12] chore: update subproject commit reference in evolution-types --- src/evolution-types | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/evolution-types b/src/evolution-types index e29f969..9f6ae51 160000 --- a/src/evolution-types +++ b/src/evolution-types @@ -1 +1 @@ -Subproject commit e29f969d605900f489a961869b435003622d9aea +Subproject commit 9f6ae5139943ba9a54b4323fb6523039c82dda9b From 8781c597ba16483b9ffb5985f956803e709c4e77 Mon Sep 17 00:00:00 2001 From: Diango Gavidia Date: Thu, 20 Nov 2025 18:42:03 -0400 Subject: [PATCH 03/12] feat: introduce lightning tournaments module with API proxying and user ranking management --- .env.example | 4 +- src/config/index.ts | 4 + .../CreateTournamentProxyUseCase.ts | 57 +++++++ .../application/GetRankingUseCase.ts | 10 ++ .../application/UpdateRankingUseCase.ts | 38 +++++ .../domain/TournamentRanking.ts | 48 ++++++ .../domain/TournamentRankingRepository.ts | 7 + .../LightningRankingPostgresRepository.ts | 61 ++++++++ .../LightningTournamentController.ts | 51 +++++++ .../TournamentsProxyController.ts | 143 ++++++++++++++++++ src/modules/user/domain/UserRepository.ts | 2 + .../infrastructure/UserPostgresRepository.ts | 18 +++ .../routes/lightning-tournament-router.ts | 32 ++++ src/server/routes/tournaments-proxy-router.ts | 9 ++ src/server/server.ts | 9 +- 15 files changed, 491 insertions(+), 2 deletions(-) create mode 100644 src/modules/lightning-tournaments/application/CreateTournamentProxyUseCase.ts create mode 100644 src/modules/lightning-tournaments/application/GetRankingUseCase.ts create mode 100644 src/modules/lightning-tournaments/application/UpdateRankingUseCase.ts create mode 100644 src/modules/lightning-tournaments/domain/TournamentRanking.ts create mode 100644 src/modules/lightning-tournaments/domain/TournamentRankingRepository.ts create mode 100644 src/modules/lightning-tournaments/infrastructure/LightningRankingPostgresRepository.ts create mode 100644 src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts create mode 100644 src/modules/lightning-tournaments/infrastructure/TournamentsProxyController.ts create mode 100644 src/server/routes/lightning-tournament-router.ts create mode 100644 src/server/routes/tournaments-proxy-router.ts diff --git a/.env.example b/.env.example index 2e9844c..0997aa2 100644 --- a/.env.example +++ b/.env.example @@ -10,4 +10,6 @@ JWT_SECRET= JWT_ISSUER= RESEND_API_KEY= RESEND_FROM_EMAIL= -SEASON= \ No newline at end of file +SEASON= +TOURNAMENTS_API_URL=http://localhost:3000 +TOURNAMENTS_WEBHOOK_URL=http://localhost:3001/api/v1/lightning-tournaments/webhook \ No newline at end of file diff --git a/src/config/index.ts b/src/config/index.ts index acf2e38..48d2946 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -25,4 +25,8 @@ export const config = { issuer: ensureEnvVariable(process.env.JWT_ISSUER as string, "JWT_ISSUER"), }, season: Number(ensureEnvVariable(process.env.SEASON as string, "SEASON")), + tournaments: { + apiUrl: ensureEnvVariable(process.env.TOURNAMENTS_API_URL as string, "TOURNAMENTS_API_URL"), + webhookUrl: ensureEnvVariable(process.env.TOURNAMENTS_WEBHOOK_URL as string, "TOURNAMENTS_WEBHOOK_URL"), + } }; diff --git a/src/modules/lightning-tournaments/application/CreateTournamentProxyUseCase.ts b/src/modules/lightning-tournaments/application/CreateTournamentProxyUseCase.ts new file mode 100644 index 0000000..4760b26 --- /dev/null +++ b/src/modules/lightning-tournaments/application/CreateTournamentProxyUseCase.ts @@ -0,0 +1,57 @@ +export interface CreateTournamentInput { + name: string; + discipline: string; + format: string; + status: string; + participantType: string; + allowMixedParticipants: boolean; + maxParticipants: number; + description?: string; + startAt?: string; + endAt?: string; + location?: string; +} + +interface Tournament { + id: string; + name: string; + description?: string | null; + discipline: string; + format: string; + status: string; + allowMixedParticipants: boolean; + participantType?: string | null; + maxParticipants?: number | null; + startAt?: string | null; + endAt?: string | null; + location?: string | null; + webhookUrl?: string | null; + metadata: Record; +} + +export class CreateTournamentProxyUseCase { + constructor( + private readonly tournamentsApiUrl: string, + private readonly webhookUrl: string + ) { } + + async execute(input: CreateTournamentInput): Promise { + const tournamentData = { + ...input, + webhookUrl: this.webhookUrl, + }; + + const response = await fetch(`${this.tournamentsApiUrl}/tournaments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(tournamentData), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to create tournament: ${response.status} ${text}`); + } + + return await response.json() as Tournament; + } +} diff --git a/src/modules/lightning-tournaments/application/GetRankingUseCase.ts b/src/modules/lightning-tournaments/application/GetRankingUseCase.ts new file mode 100644 index 0000000..f574d7f --- /dev/null +++ b/src/modules/lightning-tournaments/application/GetRankingUseCase.ts @@ -0,0 +1,10 @@ +import { TournamentRanking } from "../domain/TournamentRanking"; +import { TournamentRankingRepository } from "../domain/TournamentRankingRepository"; + +export class GetRankingUseCase { + constructor(private readonly repository: TournamentRankingRepository) { } + + async execute(limit: number = 10): Promise { + return this.repository.getTopRankings(limit); + } +} diff --git a/src/modules/lightning-tournaments/application/UpdateRankingUseCase.ts b/src/modules/lightning-tournaments/application/UpdateRankingUseCase.ts new file mode 100644 index 0000000..22d2777 --- /dev/null +++ b/src/modules/lightning-tournaments/application/UpdateRankingUseCase.ts @@ -0,0 +1,38 @@ +import { TournamentRanking } from "../domain/TournamentRanking"; +import { TournamentRankingRepository } from "../domain/TournamentRankingRepository"; +import { UserRepository } from "../../user/domain/UserRepository"; +import { Logger } from "src/shared/logger/domain/Logger"; +import { NotFoundError } from "src/shared/errors/NotFoundError"; + +export class UpdateRankingUseCase { + constructor( + private readonly repository: TournamentRankingRepository, + private readonly userRepository: UserRepository, + private readonly logger: Logger, + ) { } + + async execute(input: { participantId: string; points: number }): Promise { + this.logger.info(`[UpdateRanking] Received participantId: ${input.participantId}`); + + // 1. Find user by participantId + const user = await this.userRepository.findByParticipantId(input.participantId); + if (!user) { + throw new NotFoundError(`User not found for participantId: ${input.participantId}`); + } + + this.logger.info(`[UpdateRanking] Found user ${user.id} for participant ${input.participantId}`); + + // 2. Find or create ranking + let ranking = await this.repository.findByUserId(user.id); + + if (!ranking) { + this.logger.info(`[UpdateRanking] Creating new ranking for user ${user.id}`); + ranking = TournamentRanking.createNew(user.id); + } + + // 3. Update ranking + ranking.addWin(input.points); + await this.repository.save(ranking); + this.logger.info(`[UpdateRanking] Ranking saved for user ${user.id}`); + } +} diff --git a/src/modules/lightning-tournaments/domain/TournamentRanking.ts b/src/modules/lightning-tournaments/domain/TournamentRanking.ts new file mode 100644 index 0000000..74fe1a1 --- /dev/null +++ b/src/modules/lightning-tournaments/domain/TournamentRanking.ts @@ -0,0 +1,48 @@ +export interface TournamentRankingProps { + userId: string; + points: number; + tournamentsWon: number; + tournamentsPlayed: number; + lastUpdated: Date; +} + +export class TournamentRanking { + private constructor(private props: TournamentRankingProps) { } + + static create(props: TournamentRankingProps): TournamentRanking { + return new TournamentRanking(props); + } + + static createNew(userId: string): TournamentRanking { + return new TournamentRanking({ + userId, + points: 0, + tournamentsWon: 0, + tournamentsPlayed: 0, + lastUpdated: new Date(), + }); + } + + addWin(points: number) { + this.props.points += points; + this.props.tournamentsWon += 1; + this.props.tournamentsPlayed += 1; + this.props.lastUpdated = new Date(); + } + + addParticipation(points: number) { + this.props.points += points; + this.props.tournamentsPlayed += 1; + this.props.lastUpdated = new Date(); + } + + get userId() { return this.props.userId; } + get points() { return this.props.points; } + get tournamentsWon() { return this.props.tournamentsWon; } + get tournamentsPlayed() { return this.props.tournamentsPlayed; } + get lastUpdated() { return this.props.lastUpdated; } + + toPrimitives() { + return { ...this.props }; + } +} diff --git a/src/modules/lightning-tournaments/domain/TournamentRankingRepository.ts b/src/modules/lightning-tournaments/domain/TournamentRankingRepository.ts new file mode 100644 index 0000000..a0a94fc --- /dev/null +++ b/src/modules/lightning-tournaments/domain/TournamentRankingRepository.ts @@ -0,0 +1,7 @@ +import { TournamentRanking } from "./TournamentRanking"; + +export interface TournamentRankingRepository { + findByUserId(userId: string): Promise; + save(ranking: TournamentRanking): Promise; + getTopRankings(limit: number): Promise; +} diff --git a/src/modules/lightning-tournaments/infrastructure/LightningRankingPostgresRepository.ts b/src/modules/lightning-tournaments/infrastructure/LightningRankingPostgresRepository.ts new file mode 100644 index 0000000..911dc78 --- /dev/null +++ b/src/modules/lightning-tournaments/infrastructure/LightningRankingPostgresRepository.ts @@ -0,0 +1,61 @@ +import { config } from "src/config"; +import { dataSource } from "../../../evolution-types/src/data-source"; +import { LightningRankingEntity } from "../../../evolution-types/src/entities/LightningRankingEntity"; +import { TournamentRanking } from "../domain/TournamentRanking"; +import { TournamentRankingRepository } from "../domain/TournamentRankingRepository"; + +export class LightningRankingPostgresRepository implements TournamentRankingRepository { + async findByUserId(userId: string): Promise { + const repository = dataSource.getRepository(LightningRankingEntity); + const entity = await repository.findOne({ where: { userId } }); + + if (!entity) return null; + + return TournamentRanking.create({ + userId: entity.userId, + points: entity.points, + tournamentsWon: entity.tournamentsWon, + tournamentsPlayed: entity.tournamentsPlayed, + lastUpdated: entity.updatedAt + }); + } + + async save(ranking: TournamentRanking): Promise { + const repository = dataSource.getRepository(LightningRankingEntity); + + const existing = await repository.findOne({ where: { userId: ranking.userId } }); + + if (existing) { + existing.points = ranking.points; + existing.tournamentsWon = ranking.tournamentsWon; + existing.tournamentsPlayed = ranking.tournamentsPlayed; + await repository.save(existing); + } else { + const newEntity = repository.create({ + userId: ranking.userId, + points: ranking.points, + tournamentsWon: ranking.tournamentsWon, + tournamentsPlayed: ranking.tournamentsPlayed, + season: config.season.toString() + }); + await repository.save(newEntity); + } + } + + async getTopRankings(limit: number): Promise { + const repository = dataSource.getRepository(LightningRankingEntity); + const entities = await repository.find({ + order: { points: "DESC" }, + take: limit, + relations: ["user"] + }); + + return entities.map(entity => TournamentRanking.create({ + userId: entity.userId, + points: entity.points, + tournamentsWon: entity.tournamentsWon, + tournamentsPlayed: entity.tournamentsPlayed, + lastUpdated: entity.updatedAt + })); + } +} diff --git a/src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts b/src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts new file mode 100644 index 0000000..abc2c09 --- /dev/null +++ b/src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts @@ -0,0 +1,51 @@ +import { Elysia, t } from "elysia"; +import { UpdateRankingUseCase } from "../application/UpdateRankingUseCase"; +import { GetRankingUseCase } from "../application/GetRankingUseCase"; +import { CreateTournamentInput, CreateTournamentProxyUseCase } from "../application/CreateTournamentProxyUseCase"; +import { UserRepository } from "../../user/domain/UserRepository"; + +export class LightningTournamentController { + constructor( + private readonly updateRanking: UpdateRankingUseCase, + private readonly getRanking: GetRankingUseCase, + private readonly createTournament: CreateTournamentProxyUseCase, + private readonly userRepository: UserRepository + ) { } + + routes(app: Elysia) { + return app.group("/lightning-tournaments", (app) => + app + .post("/webhook", async ({ body }) => { + const { winnerId: participantId } = body as { winnerId: string }; + // Assign points (e.g., 10 points for a win) + await this.updateRanking.execute({ participantId, points: 10 }); + return { success: true }; + }, { + body: t.Object({ + winnerId: t.String(), // This is actually participantId from tournaments + tournamentId: t.String(), + completedAt: t.String(), + }) + }) + .get("/ranking", async ({ query }) => { + const limit = query.limit ? parseInt(query.limit) : 10; + const ranking = await this.getRanking.execute(limit); + return ranking.map(r => r.toPrimitives()); + }) + .post("/", async ({ body }) => { + const tournament = await this.createTournament.execute(body as CreateTournamentInput); + return tournament; + }) + .post("/link-participant", async ({ body }) => { + const { userId, participantId } = body as { userId: string; participantId: string }; + await this.userRepository.updateParticipantId(userId, participantId); + return { success: true }; + }, { + body: t.Object({ + userId: t.String(), + participantId: t.String(), + }) + }) + ); + } +} diff --git a/src/modules/lightning-tournaments/infrastructure/TournamentsProxyController.ts b/src/modules/lightning-tournaments/infrastructure/TournamentsProxyController.ts new file mode 100644 index 0000000..23f8bd4 --- /dev/null +++ b/src/modules/lightning-tournaments/infrastructure/TournamentsProxyController.ts @@ -0,0 +1,143 @@ +import { Elysia, t } from "elysia"; + +export class TournamentsProxyController { + constructor(private readonly tournamentsApiUrl: string) { } + + routes(app: Elysia) { + return app.group("/tournaments", (app) => + app + // Players endpoints + .post("/players", async ({ body }) => { + const response = await fetch(`${this.tournamentsApiUrl}/players`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to create player: ${response.status} ${text}`); + } + + return response.json(); + }, { + body: t.Object({ + displayName: t.String(), + email: t.String(), + countryCode: t.Optional(t.String()), + }) + }) + .get("/players/:id", async ({ params }) => { + const response = await fetch(`${this.tournamentsApiUrl}/players/${params.id}`); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to get player: ${response.status} ${text}`); + } + + return response.json(); + }) + + // Participants endpoints + .post("/participants", async ({ body }) => { + const response = await fetch(`${this.tournamentsApiUrl}/participants`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to create participant: ${response.status} ${text}`); + } + + return response.json(); + }, { + body: t.Object({ + type: t.String(), + referenceId: t.String(), + displayName: t.String(), + countryCode: t.Optional(t.String()), + }) + }) + .get("/participants/:id", async ({ params }) => { + const response = await fetch(`${this.tournamentsApiUrl}/participants/${params.id}`); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to get participant: ${response.status} ${text}`); + } + + return response.json(); + }) + + // Tournament entries endpoints + .post("/:tournamentId/entries", async ({ params, body }) => { + const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/entries`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to create entry: ${response.status} ${text}`); + } + + return response.json(); + }, { + body: t.Object({ + participantId: t.String(), + status: t.String(), + }) + }) + + // Bracket generation endpoint + .post("/:tournamentId/bracket/generate", async ({ params }) => { + const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/bracket/generate`, { + method: 'POST', + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to generate bracket: ${response.status} ${text}`); + } + + return response.json(); + }) + + // Matches endpoints + .get("/:tournamentId/matches", async ({ params }) => { + const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/matches`); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to get matches: ${response.status} ${text}`); + } + + return response.json(); + }) + .post("/:tournamentId/matches/:matchId/result", async ({ params, body }) => { + const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/matches/${params.matchId}/result`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to record match result: ${response.status} ${text}`); + } + + return response.json(); + }, { + body: t.Object({ + participants: t.Array(t.Object({ + participantId: t.String(), + score: t.Number(), + })) + }) + }) + ); + } +} diff --git a/src/modules/user/domain/UserRepository.ts b/src/modules/user/domain/UserRepository.ts index 0f0a92a..9f26cc5 100644 --- a/src/modules/user/domain/UserRepository.ts +++ b/src/modules/user/domain/UserRepository.ts @@ -6,4 +6,6 @@ export interface UserRepository { findByEmail(email: string): Promise; findById(id: string): Promise; update(user: User): Promise; + updateParticipantId(userId: string, participantId: string): Promise; + findByParticipantId(participantId: string): Promise; } diff --git a/src/modules/user/infrastructure/UserPostgresRepository.ts b/src/modules/user/infrastructure/UserPostgresRepository.ts index 77cc822..a813e69 100644 --- a/src/modules/user/infrastructure/UserPostgresRepository.ts +++ b/src/modules/user/infrastructure/UserPostgresRepository.ts @@ -70,4 +70,22 @@ export class UserPostgresRepository implements UserRepository { await repository.save(updatedUserProfileEntity); } + + async updateParticipantId(userId: string, participantId: string): Promise { + const repository = dataSource.getRepository(UserProfileEntity); + await repository.update({ id: userId }, { participantId }); + } + + async findByParticipantId(participantId: string): Promise { + const repository = dataSource.getRepository(UserProfileEntity); + const userProfileEntity = await repository.findOne({ + where: { participantId }, + }); + + if (!userProfileEntity) { + return null; + } + + return User.from(userProfileEntity); + } } diff --git a/src/server/routes/lightning-tournament-router.ts b/src/server/routes/lightning-tournament-router.ts new file mode 100644 index 0000000..67ac4e3 --- /dev/null +++ b/src/server/routes/lightning-tournament-router.ts @@ -0,0 +1,32 @@ +import { Elysia } from "elysia"; +import { CreateTournamentProxyUseCase } from "../../modules/lightning-tournaments/application/CreateTournamentProxyUseCase"; +import { GetRankingUseCase } from "../../modules/lightning-tournaments/application/GetRankingUseCase"; +import { UpdateRankingUseCase } from "../../modules/lightning-tournaments/application/UpdateRankingUseCase"; +import { LightningTournamentController } from "../../modules/lightning-tournaments/infrastructure/LightningTournamentController"; +import { LightningRankingPostgresRepository } from "../../modules/lightning-tournaments/infrastructure/LightningRankingPostgresRepository"; +import { UserPostgresRepository } from "../../modules/user/infrastructure/UserPostgresRepository"; +import { config } from "src/config"; +import { Pino } from "src/shared/logger/infrastructure/Pino"; + +const logger = new Pino(); +const repository = new LightningRankingPostgresRepository(); +const userRepository = new UserPostgresRepository(); +const updateRanking = new UpdateRankingUseCase(repository, userRepository, logger); +const getRanking = new GetRankingUseCase(repository); + +// Webhook URL will be dynamically generated in the controller based on request origin +const createTournament = new CreateTournamentProxyUseCase( + config.tournaments.apiUrl, + config.tournaments.webhookUrl, +); + +const controller = new LightningTournamentController( + updateRanking, + getRanking, + createTournament, + userRepository +); + +export const lightningTournamentRouter = new Elysia().use( + controller.routes(new Elysia()) +); diff --git a/src/server/routes/tournaments-proxy-router.ts b/src/server/routes/tournaments-proxy-router.ts new file mode 100644 index 0000000..7564bb3 --- /dev/null +++ b/src/server/routes/tournaments-proxy-router.ts @@ -0,0 +1,9 @@ +import { Elysia } from "elysia"; +import { TournamentsProxyController } from "../../modules/lightning-tournaments/infrastructure/TournamentsProxyController"; +import { config } from "src/config"; + +const controller = new TournamentsProxyController(config.tournaments.apiUrl); + +export const tournamentsProxyRouter = new Elysia().use( + controller.routes(new Elysia()) +); diff --git a/src/server/server.ts b/src/server/server.ts index dd2a828..ec33ed1 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -10,6 +10,8 @@ import { Logger } from "../shared/logger/domain/Logger"; import { banListRouter } from "./routes/ban-list-router"; import { leaderboardRouter } from "./routes/leaderboard-router"; +import { lightningTournamentRouter } from "./routes/lightning-tournament-router"; +import { tournamentsProxyRouter } from "./routes/tournaments-proxy-router"; import { userRouter } from "./routes/user-router"; export class Server { @@ -40,7 +42,12 @@ export class Server { // @ts-expect-error linter not config correctly this.app.group("/api/v1", (app: Elysia) => { - return app.use(userRouter).use(leaderboardRouter).use(banListRouter); + return app + .use(userRouter) + .use(leaderboardRouter) + .use(banListRouter) + .use(lightningTournamentRouter) + .use(tournamentsProxyRouter); }); this.logger = logger; } From 3f54600d17b7610aa74a7a678ba758078a1c27b5 Mon Sep 17 00:00:00 2001 From: Diango Gavidia Date: Thu, 20 Nov 2025 19:44:57 -0400 Subject: [PATCH 04/12] refactor: extract player, participant creation at tournament enrollment use case --- .../TournamentEnrollmentUseCase.ts | 24 +++++++ .../domain/TournamentRepository.ts | 4 ++ .../LightningTournamentController.ts | 14 +++- .../infrastructure/TournamentGateway.ts | 49 ++++++++++++++ .../TournamentsProxyController.ts | 66 ------------------- src/modules/user/application/UserRegister.ts | 2 +- src/modules/user/domain/User.ts | 9 ++- .../routes/lightning-tournament-router.ts | 7 +- 8 files changed, 104 insertions(+), 71 deletions(-) create mode 100644 src/modules/lightning-tournaments/application/TournamentEnrollmentUseCase.ts create mode 100644 src/modules/lightning-tournaments/domain/TournamentRepository.ts create mode 100644 src/modules/lightning-tournaments/infrastructure/TournamentGateway.ts diff --git a/src/modules/lightning-tournaments/application/TournamentEnrollmentUseCase.ts b/src/modules/lightning-tournaments/application/TournamentEnrollmentUseCase.ts new file mode 100644 index 0000000..54994dd --- /dev/null +++ b/src/modules/lightning-tournaments/application/TournamentEnrollmentUseCase.ts @@ -0,0 +1,24 @@ +import { UserRepository } from "src/modules/user/domain/UserRepository"; +import { NotFoundError } from "src/shared/errors/NotFoundError"; +import { TournamentRepository } from "../domain/TournamentRepository"; + +export class TournamentEnrollmentUseCase { + constructor(private readonly userRepository: UserRepository, private readonly tournamentRepository: TournamentRepository) { } + + async execute({ userId, tournamentId }: { userId: string; tournamentId: string }): Promise { + const user = await this.userRepository.findById(userId) + if (!user) { + throw new NotFoundError(`User with id: ${userId} not found`) + } + + if (!user.participantId) { + const participantId = await this.tournamentRepository.createUserTournament({ displayName: user.username, email: user.email }); + await this.userRepository.updateParticipantId(userId, participantId); + await this.tournamentRepository.enrollTournament({ tournamentId, participantId }); + return + } + + await this.tournamentRepository.enrollTournament({ tournamentId, participantId: user.participantId }); + + } +} \ No newline at end of file diff --git a/src/modules/lightning-tournaments/domain/TournamentRepository.ts b/src/modules/lightning-tournaments/domain/TournamentRepository.ts new file mode 100644 index 0000000..4705dca --- /dev/null +++ b/src/modules/lightning-tournaments/domain/TournamentRepository.ts @@ -0,0 +1,4 @@ +export interface TournamentRepository { + createUserTournament({ displayName, email }: { displayName: string; email: string; }): Promise; + enrollTournament({ tournamentId, participantId }: { tournamentId: string; participantId: string; }): Promise; +} \ No newline at end of file diff --git a/src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts b/src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts index abc2c09..98131d5 100644 --- a/src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts +++ b/src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts @@ -3,13 +3,15 @@ import { UpdateRankingUseCase } from "../application/UpdateRankingUseCase"; import { GetRankingUseCase } from "../application/GetRankingUseCase"; import { CreateTournamentInput, CreateTournamentProxyUseCase } from "../application/CreateTournamentProxyUseCase"; import { UserRepository } from "../../user/domain/UserRepository"; +import { TournamentEnrollmentUseCase } from "../application/TournamentEnrollmentUseCase"; export class LightningTournamentController { constructor( private readonly updateRanking: UpdateRankingUseCase, private readonly getRanking: GetRankingUseCase, private readonly createTournament: CreateTournamentProxyUseCase, - private readonly userRepository: UserRepository + private readonly userRepository: UserRepository, + private readonly tournamentEnrollmentUseCase: TournamentEnrollmentUseCase ) { } routes(app: Elysia) { @@ -46,6 +48,16 @@ export class LightningTournamentController { participantId: t.String(), }) }) + .post("/enroll", async ({ body }) => { + const { userId, tournamentId } = body as { userId: string; tournamentId: string }; + await this.tournamentEnrollmentUseCase.execute({ userId, tournamentId }); + return { success: true }; + }, { + body: t.Object({ + userId: t.String(), + tournamentId: t.String(), + }) + }) ); } } diff --git a/src/modules/lightning-tournaments/infrastructure/TournamentGateway.ts b/src/modules/lightning-tournaments/infrastructure/TournamentGateway.ts new file mode 100644 index 0000000..7836d05 --- /dev/null +++ b/src/modules/lightning-tournaments/infrastructure/TournamentGateway.ts @@ -0,0 +1,49 @@ +import { config } from "src/config"; +import { TournamentRepository } from "../domain/TournamentRepository"; + +export class TournamentGateway implements TournamentRepository { + async createUserTournament({ displayName, email }: { displayName: string; email: string; }): Promise { + const createPlayerResponse = await fetch(`${config.tournaments.apiUrl}/players`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ displayName, email }), + }); + + if (!createPlayerResponse.ok) { + const text = await createPlayerResponse.text(); + throw new Error(`Failed to create player: ${createPlayerResponse.status} ${text}`); + } + + const player = await createPlayerResponse.json(); + + const response = await fetch(`${config.tournaments.apiUrl}/participants`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'PLAYER', referenceId: player.id, displayName }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to create participant: ${response.status} ${text}`); + } + + const participant = await response.json(); + return participant.id; + } + + async enrollTournament({ tournamentId, participantId }: { tournamentId: string; participantId: string; }): Promise { + const response = await fetch(`${config.tournaments.apiUrl}/tournaments/${tournamentId}/entries`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ participantId, status: 'CONFIRMED' }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to create entry: ${response.status} ${text}`); + } + + return; + } + +} \ No newline at end of file diff --git a/src/modules/lightning-tournaments/infrastructure/TournamentsProxyController.ts b/src/modules/lightning-tournaments/infrastructure/TournamentsProxyController.ts index 23f8bd4..1daf51a 100644 --- a/src/modules/lightning-tournaments/infrastructure/TournamentsProxyController.ts +++ b/src/modules/lightning-tournaments/infrastructure/TournamentsProxyController.ts @@ -6,27 +6,6 @@ export class TournamentsProxyController { routes(app: Elysia) { return app.group("/tournaments", (app) => app - // Players endpoints - .post("/players", async ({ body }) => { - const response = await fetch(`${this.tournamentsApiUrl}/players`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Failed to create player: ${response.status} ${text}`); - } - - return response.json(); - }, { - body: t.Object({ - displayName: t.String(), - email: t.String(), - countryCode: t.Optional(t.String()), - }) - }) .get("/players/:id", async ({ params }) => { const response = await fetch(`${this.tournamentsApiUrl}/players/${params.id}`); @@ -37,29 +16,6 @@ export class TournamentsProxyController { return response.json(); }) - - // Participants endpoints - .post("/participants", async ({ body }) => { - const response = await fetch(`${this.tournamentsApiUrl}/participants`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Failed to create participant: ${response.status} ${text}`); - } - - return response.json(); - }, { - body: t.Object({ - type: t.String(), - referenceId: t.String(), - displayName: t.String(), - countryCode: t.Optional(t.String()), - }) - }) .get("/participants/:id", async ({ params }) => { const response = await fetch(`${this.tournamentsApiUrl}/participants/${params.id}`); @@ -70,28 +26,6 @@ export class TournamentsProxyController { return response.json(); }) - - // Tournament entries endpoints - .post("/:tournamentId/entries", async ({ params, body }) => { - const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/entries`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Failed to create entry: ${response.status} ${text}`); - } - - return response.json(); - }, { - body: t.Object({ - participantId: t.String(), - status: t.String(), - }) - }) - // Bracket generation endpoint .post("/:tournamentId/bracket/generate", async ({ params }) => { const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/bracket/generate`, { diff --git a/src/modules/user/application/UserRegister.ts b/src/modules/user/application/UserRegister.ts index b0937a9..698a462 100644 --- a/src/modules/user/application/UserRegister.ts +++ b/src/modules/user/application/UserRegister.ts @@ -12,7 +12,7 @@ export class UserRegister { private readonly hash: Hash, private readonly logger: Logger, private readonly emailSender: EmailSender, - ) {} + ) { } async register({ id, email, username }: { id: string; email: string; username: string }): Promise { this.logger.info(`Creating new user ${email}`); diff --git a/src/modules/user/domain/User.ts b/src/modules/user/domain/User.ts index 153181c..d093620 100644 --- a/src/modules/user/domain/User.ts +++ b/src/modules/user/domain/User.ts @@ -7,6 +7,7 @@ export class User { public readonly password: string; private _username: string; public readonly role: UserProfileRole; + public participantId: string | null; private constructor({ id, @@ -14,18 +15,21 @@ export class User { email, password, role, + participantId, }: { id: string; username: string; email: string; password: string; role: UserProfileRole; + participantId: string | null; }) { this.id = id; this._username = username; this.email = email; this.password = password; this.role = role; + this.participantId = participantId; } static create({ @@ -53,10 +57,10 @@ export class User { if (!password.trim()) { throw new InvalidArgumentError(`password cannot be empty`); } - return new User({ id, username, email, password, role }); + return new User({ id, username, email, password, role, participantId: null }); } - static from(data: { id: string; username: string; password: string; email: string; role: UserProfileRole }): User { + static from(data: { id: string; username: string; password: string; email: string; role: UserProfileRole; participantId: string | null }): User { return new User(data); } @@ -83,6 +87,7 @@ export class User { password, email: this.email, role: this.role, + participantId: this.participantId, }); } diff --git a/src/server/routes/lightning-tournament-router.ts b/src/server/routes/lightning-tournament-router.ts index 67ac4e3..55e6497 100644 --- a/src/server/routes/lightning-tournament-router.ts +++ b/src/server/routes/lightning-tournament-router.ts @@ -7,12 +7,16 @@ import { LightningRankingPostgresRepository } from "../../modules/lightning-tour import { UserPostgresRepository } from "../../modules/user/infrastructure/UserPostgresRepository"; import { config } from "src/config"; import { Pino } from "src/shared/logger/infrastructure/Pino"; +import { TournamentEnrollmentUseCase } from "src/modules/lightning-tournaments/application/TournamentEnrollmentUseCase"; +import { TournamentGateway } from "src/modules/lightning-tournaments/infrastructure/TournamentGateway"; const logger = new Pino(); const repository = new LightningRankingPostgresRepository(); const userRepository = new UserPostgresRepository(); +const tournamentRepository = new TournamentGateway(); const updateRanking = new UpdateRankingUseCase(repository, userRepository, logger); const getRanking = new GetRankingUseCase(repository); +const tournamentEnrollmentUseCase = new TournamentEnrollmentUseCase(userRepository, tournamentRepository); // Webhook URL will be dynamically generated in the controller based on request origin const createTournament = new CreateTournamentProxyUseCase( @@ -24,7 +28,8 @@ const controller = new LightningTournamentController( updateRanking, getRanking, createTournament, - userRepository + userRepository, + tournamentEnrollmentUseCase ); export const lightningTournamentRouter = new Elysia().use( From ee56fbf8dd8202b165377f50e435c4a992e33988 Mon Sep 17 00:00:00 2001 From: Diango Gavidia Date: Thu, 20 Nov 2025 19:48:55 -0400 Subject: [PATCH 05/12] test: add `participantId` to `UserMother` and mock new user repository methods in tests --- tests/unit/modules/auth/application/UserAuth.test.ts | 6 ++++-- tests/unit/modules/users/application/UserRegister.test.ts | 2 ++ .../modules/users/application/UserUsernameUpdater.test.ts | 2 ++ tests/unit/modules/users/mothers/UserMother.ts | 1 + 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/unit/modules/auth/application/UserAuth.test.ts b/tests/unit/modules/auth/application/UserAuth.test.ts index 77a86b6..e16ddc2 100644 --- a/tests/unit/modules/auth/application/UserAuth.test.ts +++ b/tests/unit/modules/auth/application/UserAuth.test.ts @@ -21,11 +21,13 @@ describe("UserAuth", () => { jwt = new JWT({ issuer: "issuer", secret: "secret" }); repository = { - create: async () => {}, + create: async () => { }, findByEmailOrUsername: async () => null, findByEmail: async () => null, findById: async () => null, - update: async () => {}, + update: async () => { }, + updateParticipantId: async () => { }, + findByParticipantId: async () => null, }; userAuth = new UserAuth(repository, hash, jwt); diff --git a/tests/unit/modules/users/application/UserRegister.test.ts b/tests/unit/modules/users/application/UserRegister.test.ts index a42e774..c4b6be2 100644 --- a/tests/unit/modules/users/application/UserRegister.test.ts +++ b/tests/unit/modules/users/application/UserRegister.test.ts @@ -25,6 +25,8 @@ describe("UserRegister", () => { findByEmail: async () => null, findById: async () => null, update: async () => undefined, + updateParticipantId: async () => undefined, + findByParticipantId: async () => null, } hash = new Hash(); logger = new Pino(); diff --git a/tests/unit/modules/users/application/UserUsernameUpdater.test.ts b/tests/unit/modules/users/application/UserUsernameUpdater.test.ts index e8c03d0..b8bc207 100644 --- a/tests/unit/modules/users/application/UserUsernameUpdater.test.ts +++ b/tests/unit/modules/users/application/UserUsernameUpdater.test.ts @@ -19,6 +19,8 @@ describe("User UsernameUpdater", () => { findByEmail: async () => null, findById: async () => null, update: async () => undefined, + updateParticipantId: async () => undefined, + findByParticipantId: async () => null, } user = UserMother.create(); spyOn(repository, "findById").mockResolvedValue(user); diff --git a/tests/unit/modules/users/mothers/UserMother.ts b/tests/unit/modules/users/mothers/UserMother.ts index 363cfcf..8e29e7e 100644 --- a/tests/unit/modules/users/mothers/UserMother.ts +++ b/tests/unit/modules/users/mothers/UserMother.ts @@ -11,6 +11,7 @@ export class UserMother { email: faker.internet.email(), password: faker.internet.password(), role: UserProfileRole.USER, + participantId: null, ...params, }); } From 5a96d106dff52e856cd2db3ecacb6043f098404d Mon Sep 17 00:00:00 2001 From: Diango Gavidia Date: Fri, 21 Nov 2025 10:06:36 -0400 Subject: [PATCH 06/12] feat: add ADMIN role protection and banlist metadata - Add bearer auth and ADMIN role checks to tournament endpoints - Restrict tournament creation to ADMIN users only - Restrict bracket generation to ADMIN users only - Restrict match result recording to ADMIN users only - Add optional banlist field for format identification (e.g., Edison, TCG, OCG) - Banlist stored in tournament metadata - Inject JWT dependency into TournamentsProxyController - Update routers to pass JWT instance to controllers BREAKING CHANGE: Tournament creation, bracket generation, and match result recording now require ADMIN role --- .env.test | 5 +++- src/evolution-types | 2 +- .../CreateTournamentProxyUseCase.ts | 6 ++++- .../LightningTournamentController.ts | 26 +++++++++---------- .../TournamentsProxyController.ts | 24 ++++++++++++++--- .../routes/lightning-tournament-router.ts | 6 +++-- src/server/routes/tournaments-proxy-router.ts | 4 ++- 7 files changed, 50 insertions(+), 23 deletions(-) diff --git a/.env.test b/.env.test index 0ab4e59..9290cc4 100644 --- a/.env.test +++ b/.env.test @@ -10,4 +10,7 @@ JWT_SECRET=JWT_SECRET JWT_ISSUER=JWT_ISSUER RESEND_API_KEY=RESEND_API_KEY RESEND_FROM_EMAIL=RESEND_FROM_EMAIL -SEASON=SEASON \ No newline at end of file +SEASON=5 +PORT=3001 +TOURNAMENTS_API_URL=http://localhost:3000 +TOURNAMENTS_WEBHOOK_URL=http://localhost:3001/api/v1/lightning-tournaments/webhook diff --git a/src/evolution-types b/src/evolution-types index 9f6ae51..25afaf8 160000 --- a/src/evolution-types +++ b/src/evolution-types @@ -1 +1 @@ -Subproject commit 9f6ae5139943ba9a54b4323fb6523039c82dda9b +Subproject commit 25afaf8f0c899341c6d2ede9fc41386863f862b7 diff --git a/src/modules/lightning-tournaments/application/CreateTournamentProxyUseCase.ts b/src/modules/lightning-tournaments/application/CreateTournamentProxyUseCase.ts index 4760b26..1f11d2f 100644 --- a/src/modules/lightning-tournaments/application/CreateTournamentProxyUseCase.ts +++ b/src/modules/lightning-tournaments/application/CreateTournamentProxyUseCase.ts @@ -10,6 +10,7 @@ export interface CreateTournamentInput { startAt?: string; endAt?: string; location?: string; + banlist?: string; // e.g., "Edison", "TCG", "OCG", "Goat" } interface Tournament { @@ -36,9 +37,12 @@ export class CreateTournamentProxyUseCase { ) { } async execute(input: CreateTournamentInput): Promise { + const { banlist, ...tournamentFields } = input; + const tournamentData = { - ...input, + ...tournamentFields, webhookUrl: this.webhookUrl, + metadata: banlist ? { banlist } : {} }; const response = await fetch(`${this.tournamentsApiUrl}/tournaments`, { diff --git a/src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts b/src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts index 98131d5..7bc06c6 100644 --- a/src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts +++ b/src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts @@ -1,22 +1,26 @@ import { Elysia, t } from "elysia"; +import { bearer } from "@elysiajs/bearer"; import { UpdateRankingUseCase } from "../application/UpdateRankingUseCase"; import { GetRankingUseCase } from "../application/GetRankingUseCase"; import { CreateTournamentInput, CreateTournamentProxyUseCase } from "../application/CreateTournamentProxyUseCase"; -import { UserRepository } from "../../user/domain/UserRepository"; import { TournamentEnrollmentUseCase } from "../application/TournamentEnrollmentUseCase"; +import { JWT } from "src/shared/JWT"; +import { UserProfileRole } from "src/evolution-types/src/types/UserProfileRole"; +import { UnauthorizedError } from "src/shared/errors/UnauthorizedError"; export class LightningTournamentController { constructor( private readonly updateRanking: UpdateRankingUseCase, private readonly getRanking: GetRankingUseCase, private readonly createTournament: CreateTournamentProxyUseCase, - private readonly userRepository: UserRepository, - private readonly tournamentEnrollmentUseCase: TournamentEnrollmentUseCase + private readonly tournamentEnrollmentUseCase: TournamentEnrollmentUseCase, + private readonly jwt: JWT ) { } routes(app: Elysia) { return app.group("/lightning-tournaments", (app) => app + .use(bearer()) .post("/webhook", async ({ body }) => { const { winnerId: participantId } = body as { winnerId: string }; // Assign points (e.g., 10 points for a win) @@ -34,20 +38,14 @@ export class LightningTournamentController { const ranking = await this.getRanking.execute(limit); return ranking.map(r => r.toPrimitives()); }) - .post("/", async ({ body }) => { + .post("/", async ({ body, bearer }) => { + const { role } = this.jwt.decode(bearer as string) as { role: string }; + if (role !== UserProfileRole.ADMIN) { + throw new UnauthorizedError("You do not have permission to create tournaments"); + } const tournament = await this.createTournament.execute(body as CreateTournamentInput); return tournament; }) - .post("/link-participant", async ({ body }) => { - const { userId, participantId } = body as { userId: string; participantId: string }; - await this.userRepository.updateParticipantId(userId, participantId); - return { success: true }; - }, { - body: t.Object({ - userId: t.String(), - participantId: t.String(), - }) - }) .post("/enroll", async ({ body }) => { const { userId, tournamentId } = body as { userId: string; tournamentId: string }; await this.tournamentEnrollmentUseCase.execute({ userId, tournamentId }); diff --git a/src/modules/lightning-tournaments/infrastructure/TournamentsProxyController.ts b/src/modules/lightning-tournaments/infrastructure/TournamentsProxyController.ts index 1daf51a..cd23674 100644 --- a/src/modules/lightning-tournaments/infrastructure/TournamentsProxyController.ts +++ b/src/modules/lightning-tournaments/infrastructure/TournamentsProxyController.ts @@ -1,11 +1,19 @@ import { Elysia, t } from "elysia"; +import { bearer } from "@elysiajs/bearer"; +import { JWT } from "src/shared/JWT"; +import { UserProfileRole } from "src/evolution-types/src/types/UserProfileRole"; +import { UnauthorizedError } from "src/shared/errors/UnauthorizedError"; export class TournamentsProxyController { - constructor(private readonly tournamentsApiUrl: string) { } + constructor( + private readonly tournamentsApiUrl: string, + private readonly jwt: JWT + ) { } routes(app: Elysia) { return app.group("/tournaments", (app) => app + .use(bearer()) .get("/players/:id", async ({ params }) => { const response = await fetch(`${this.tournamentsApiUrl}/players/${params.id}`); @@ -27,7 +35,12 @@ export class TournamentsProxyController { return response.json(); }) // Bracket generation endpoint - .post("/:tournamentId/bracket/generate", async ({ params }) => { + .post("/:tournamentId/bracket/generate", async ({ params, bearer }) => { + const { role } = this.jwt.decode(bearer as string) as { role: string }; + if (role !== UserProfileRole.ADMIN) { + throw new UnauthorizedError("You do not have permission to generate brackets"); + } + const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/bracket/generate`, { method: 'POST', }); @@ -51,7 +64,12 @@ export class TournamentsProxyController { return response.json(); }) - .post("/:tournamentId/matches/:matchId/result", async ({ params, body }) => { + .post("/:tournamentId/matches/:matchId/result", async ({ params, body, bearer }) => { + const { role } = this.jwt.decode(bearer as string) as { role: string }; + if (role !== UserProfileRole.ADMIN) { + throw new UnauthorizedError("You do not have permission to record match results"); + } + const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/matches/${params.matchId}/result`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/src/server/routes/lightning-tournament-router.ts b/src/server/routes/lightning-tournament-router.ts index 55e6497..b7280ee 100644 --- a/src/server/routes/lightning-tournament-router.ts +++ b/src/server/routes/lightning-tournament-router.ts @@ -9,6 +9,7 @@ import { config } from "src/config"; import { Pino } from "src/shared/logger/infrastructure/Pino"; import { TournamentEnrollmentUseCase } from "src/modules/lightning-tournaments/application/TournamentEnrollmentUseCase"; import { TournamentGateway } from "src/modules/lightning-tournaments/infrastructure/TournamentGateway"; +import { JWT } from "src/shared/JWT"; const logger = new Pino(); const repository = new LightningRankingPostgresRepository(); @@ -17,6 +18,7 @@ const tournamentRepository = new TournamentGateway(); const updateRanking = new UpdateRankingUseCase(repository, userRepository, logger); const getRanking = new GetRankingUseCase(repository); const tournamentEnrollmentUseCase = new TournamentEnrollmentUseCase(userRepository, tournamentRepository); +const jwt = new JWT(config.jwt) // Webhook URL will be dynamically generated in the controller based on request origin const createTournament = new CreateTournamentProxyUseCase( @@ -28,8 +30,8 @@ const controller = new LightningTournamentController( updateRanking, getRanking, createTournament, - userRepository, - tournamentEnrollmentUseCase + tournamentEnrollmentUseCase, + jwt ); export const lightningTournamentRouter = new Elysia().use( diff --git a/src/server/routes/tournaments-proxy-router.ts b/src/server/routes/tournaments-proxy-router.ts index 7564bb3..a65b5a9 100644 --- a/src/server/routes/tournaments-proxy-router.ts +++ b/src/server/routes/tournaments-proxy-router.ts @@ -1,8 +1,10 @@ import { Elysia } from "elysia"; import { TournamentsProxyController } from "../../modules/lightning-tournaments/infrastructure/TournamentsProxyController"; import { config } from "src/config"; +import { JWT } from "src/shared/JWT"; -const controller = new TournamentsProxyController(config.tournaments.apiUrl); +const jwt = new JWT(config.jwt); +const controller = new TournamentsProxyController(config.tournaments.apiUrl, jwt); export const tournamentsProxyRouter = new Elysia().use( controller.routes(new Elysia()) From 34dd0568560847a6b1317b9a2ab72efc699e8174 Mon Sep 17 00:00:00 2001 From: Diango Gavidia Date: Fri, 21 Nov 2025 10:25:25 -0400 Subject: [PATCH 07/12] feat: implement position-based points distribution system - Refactor UpdateRankingUseCase to fetch matches and calculate final tournament rankings - Distribute points to all participants based on position: 1st=10pts, 2nd=7pts, 3rd/4th=5pts - Make TournamentRanking domain immutable with functional methods - Replace console.log with project logger (Pino) - Update webhook to process all participants automatically - Enhance verify.ts to validate points distribution Points are now awarded to all tournament participants, not just the winner, creating a fairer reward system. --- .../application/UpdateRankingUseCase.ts | 140 +++++++++++++++--- .../domain/TournamentRanking.ts | 100 +++++++++---- .../LightningRankingPostgresRepository.ts | 10 +- .../LightningTournamentController.ts | 8 +- .../routes/lightning-tournament-router.ts | 9 +- 5 files changed, 209 insertions(+), 58 deletions(-) diff --git a/src/modules/lightning-tournaments/application/UpdateRankingUseCase.ts b/src/modules/lightning-tournaments/application/UpdateRankingUseCase.ts index 22d2777..089be14 100644 --- a/src/modules/lightning-tournaments/application/UpdateRankingUseCase.ts +++ b/src/modules/lightning-tournaments/application/UpdateRankingUseCase.ts @@ -1,38 +1,138 @@ -import { TournamentRanking } from "../domain/TournamentRanking"; -import { TournamentRankingRepository } from "../domain/TournamentRankingRepository"; import { UserRepository } from "../../user/domain/UserRepository"; +import { TournamentRankingRepository } from "../domain/TournamentRankingRepository"; +import { TournamentRanking } from "../domain/TournamentRanking"; import { Logger } from "src/shared/logger/domain/Logger"; -import { NotFoundError } from "src/shared/errors/NotFoundError"; + +// Fixed points distribution by position +const POINTS_BY_POSITION: Record = { + 1: 10, // 1st place + 2: 7, // 2nd place + 3: 5, // 3rd place + 4: 3, // 4th place + 5: 2, // 5th-8th place + 6: 2, + 7: 2, + 8: 2, +}; + +interface MatchParticipant { + participantId: string; + score: number | null; + result: "win" | "loss" | "draw" | null; +} + +interface Match { + id: string; + roundNumber: number; + completedAt: string | null; + participants: MatchParticipant[]; +} + +interface RankingEntry { + participantId: string; + position: number; +} export class UpdateRankingUseCase { constructor( private readonly repository: TournamentRankingRepository, private readonly userRepository: UserRepository, - private readonly logger: Logger, + private readonly tournamentsApiUrl: string, + private readonly logger: Logger ) { } - async execute(input: { participantId: string; points: number }): Promise { - this.logger.info(`[UpdateRanking] Received participantId: ${input.participantId}`); + async execute(input: { tournamentId: string }): Promise { + this.logger.info(`[UpdateRanking] Processing tournament: ${input.tournamentId}`); + + // Fetch all matches from tournaments service + const matches = await this.fetchTournamentMatches(input.tournamentId); + this.logger.info(`[UpdateRanking] Found ${matches.length} matches`); + + // Calculate final rankings + const rankings = this.calculateRankings(matches); + this.logger.debug(`[UpdateRanking] Calculated rankings: ${JSON.stringify(rankings)}`); - // 1. Find user by participantId - const user = await this.userRepository.findByParticipantId(input.participantId); - if (!user) { - throw new NotFoundError(`User not found for participantId: ${input.participantId}`); + + // Update points for each participant + for (const ranking of rankings) { + const points = POINTS_BY_POSITION[ranking.position] || 1; + const isWinner = ranking.position === 1; + + // Find user by participantId + const user = await this.userRepository.findByParticipantId(ranking.participantId); + if (!user) { + this.logger.error(`[UpdateRanking] User not found for participant ${ranking.participantId}`); + continue; + } + + this.logger.info(`[UpdateRanking] Updating user ${user.id}: position ${ranking.position}, points ${points}`); + + + // Get or create ranking + let userRanking = await this.repository.findByUserId(user.id); + + if (!userRanking) { + userRanking = TournamentRanking.createNew({ + userId: user.id, + points, + tournamentsWon: isWinner ? 1 : 0, + tournamentsPlayed: 1, + }); + } else { + userRanking = userRanking.addPoints(points); + userRanking = userRanking.incrementTournamentsPlayed(); + if (isWinner) { + userRanking = userRanking.incrementTournamentsWon(); + } + } + + await this.repository.save(userRanking); + this.logger.info(`[UpdateRanking] Saved ranking for user ${user.id}`); } + } + + private async fetchTournamentMatches(tournamentId: string): Promise { + const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${tournamentId}/matches`); + if (!response.ok) { + throw new Error(`Failed to fetch matches: ${response.status}`); + } + return await response.json(); + } + + private calculateRankings(matches: Match[]): RankingEntry[] { + // For single elimination, we can determine positions from the bracket structure + // The winner of the final (highest round) is 1st + // The loser of the final is 2nd + // The losers of semi-finals are tied 3rd + // etc. + + const maxRound = Math.max(...matches.map(m => m.roundNumber)); + const rankings: RankingEntry[] = []; - this.logger.info(`[UpdateRanking] Found user ${user.id} for participant ${input.participantId}`); + // Process rounds from final to first + for (let round = maxRound; round >= 1; round--) { + const roundMatches = matches.filter(m => m.roundNumber === round && m.completedAt); - // 2. Find or create ranking - let ranking = await this.repository.findByUserId(user.id); + for (const match of roundMatches) { + const winner = match.participants.find(p => p.result === "win"); + const loser = match.participants.find(p => p.result === "loss"); - if (!ranking) { - this.logger.info(`[UpdateRanking] Creating new ranking for user ${user.id}`); - ranking = TournamentRanking.createNew(user.id); + if (round === maxRound) { + // Final match + if (winner) rankings.push({ participantId: winner.participantId, position: 1 }); + if (loser) rankings.push({ participantId: loser.participantId, position: 2 }); + } else { + // For earlier rounds, losers get positions based on round + // Semi-final losers: 3rd place + // Quarter-final losers: 5th place + const position = Math.pow(2, maxRound - round) + 1; + if (loser && !rankings.find(r => r.participantId === loser.participantId)) { + rankings.push({ participantId: loser.participantId, position }); + } + } + } } - // 3. Update ranking - ranking.addWin(input.points); - await this.repository.save(ranking); - this.logger.info(`[UpdateRanking] Ranking saved for user ${user.id}`); + return rankings; } } diff --git a/src/modules/lightning-tournaments/domain/TournamentRanking.ts b/src/modules/lightning-tournaments/domain/TournamentRanking.ts index 74fe1a1..bc48b72 100644 --- a/src/modules/lightning-tournaments/domain/TournamentRanking.ts +++ b/src/modules/lightning-tournaments/domain/TournamentRanking.ts @@ -7,42 +7,90 @@ export interface TournamentRankingProps { } export class TournamentRanking { - private constructor(private props: TournamentRankingProps) { } + private constructor( + private readonly userId: string, + private readonly _points: number, + private readonly _tournamentsWon: number, + private readonly _tournamentsPlayed: number + ) { } - static create(props: TournamentRankingProps): TournamentRanking { - return new TournamentRanking(props); + static createNew(props: { + userId: string; + points: number; + tournamentsWon: number; + tournamentsPlayed: number; + }): TournamentRanking { + return new TournamentRanking( + props.userId, + props.points, + props.tournamentsWon, + props.tournamentsPlayed + ); } - static createNew(userId: string): TournamentRanking { - return new TournamentRanking({ - userId, - points: 0, - tournamentsWon: 0, - tournamentsPlayed: 0, - lastUpdated: new Date(), - }); + static fromPrimitives(props: { + userId: string; + points: number; + tournamentsWon: number; + tournamentsPlayed: number; + }): TournamentRanking { + return new TournamentRanking( + props.userId, + props.points, + props.tournamentsWon, + props.tournamentsPlayed + ); } - addWin(points: number) { - this.props.points += points; - this.props.tournamentsWon += 1; - this.props.tournamentsPlayed += 1; - this.props.lastUpdated = new Date(); + addPoints(points: number): TournamentRanking { + return new TournamentRanking( + this.userId, + this._points + points, + this._tournamentsWon, + this._tournamentsPlayed + ); } - addParticipation(points: number) { - this.props.points += points; - this.props.tournamentsPlayed += 1; - this.props.lastUpdated = new Date(); + incrementTournamentsPlayed(): TournamentRanking { + return new TournamentRanking( + this.userId, + this._points, + this._tournamentsWon, + this._tournamentsPlayed + 1 + ); } - get userId() { return this.props.userId; } - get points() { return this.props.points; } - get tournamentsWon() { return this.props.tournamentsWon; } - get tournamentsPlayed() { return this.props.tournamentsPlayed; } - get lastUpdated() { return this.props.lastUpdated; } + incrementTournamentsWon(): TournamentRanking { + return new TournamentRanking( + this.userId, + this._points, + this._tournamentsWon + 1, + this._tournamentsPlayed + ); + } + + get points(): number { + return this._points; + } + + get tournamentsWon(): number { + return this._tournamentsWon; + } + + get tournamentsPlayed(): number { + return this._tournamentsPlayed; + } + + getUserId(): string { + return this.userId; + } toPrimitives() { - return { ...this.props }; + return { + userId: this.userId, + points: this._points, + tournamentsWon: this._tournamentsWon, + tournamentsPlayed: this._tournamentsPlayed, + }; } } diff --git a/src/modules/lightning-tournaments/infrastructure/LightningRankingPostgresRepository.ts b/src/modules/lightning-tournaments/infrastructure/LightningRankingPostgresRepository.ts index 911dc78..1b9c392 100644 --- a/src/modules/lightning-tournaments/infrastructure/LightningRankingPostgresRepository.ts +++ b/src/modules/lightning-tournaments/infrastructure/LightningRankingPostgresRepository.ts @@ -11,19 +11,18 @@ export class LightningRankingPostgresRepository implements TournamentRankingRepo if (!entity) return null; - return TournamentRanking.create({ + return TournamentRanking.fromPrimitives({ userId: entity.userId, points: entity.points, tournamentsWon: entity.tournamentsWon, tournamentsPlayed: entity.tournamentsPlayed, - lastUpdated: entity.updatedAt }); } async save(ranking: TournamentRanking): Promise { const repository = dataSource.getRepository(LightningRankingEntity); - const existing = await repository.findOne({ where: { userId: ranking.userId } }); + const existing = await repository.findOne({ where: { userId: ranking.getUserId() } }); if (existing) { existing.points = ranking.points; @@ -32,7 +31,7 @@ export class LightningRankingPostgresRepository implements TournamentRankingRepo await repository.save(existing); } else { const newEntity = repository.create({ - userId: ranking.userId, + userId: ranking.getUserId(), points: ranking.points, tournamentsWon: ranking.tournamentsWon, tournamentsPlayed: ranking.tournamentsPlayed, @@ -50,12 +49,11 @@ export class LightningRankingPostgresRepository implements TournamentRankingRepo relations: ["user"] }); - return entities.map(entity => TournamentRanking.create({ + return entities.map(entity => TournamentRanking.fromPrimitives({ userId: entity.userId, points: entity.points, tournamentsWon: entity.tournamentsWon, tournamentsPlayed: entity.tournamentsPlayed, - lastUpdated: entity.updatedAt })); } } diff --git a/src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts b/src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts index 7bc06c6..cbc2f53 100644 --- a/src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts +++ b/src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts @@ -22,13 +22,13 @@ export class LightningTournamentController { app .use(bearer()) .post("/webhook", async ({ body }) => { - const { winnerId: participantId } = body as { winnerId: string }; - // Assign points (e.g., 10 points for a win) - await this.updateRanking.execute({ participantId, points: 10 }); + const { tournamentId } = body as { tournamentId: string; winnerId: string; completedAt: string }; + // Process all participants' rankings based on final positions + await this.updateRanking.execute({ tournamentId }); return { success: true }; }, { body: t.Object({ - winnerId: t.String(), // This is actually participantId from tournaments + winnerId: t.String(), tournamentId: t.String(), completedAt: t.String(), }) diff --git a/src/server/routes/lightning-tournament-router.ts b/src/server/routes/lightning-tournament-router.ts index b7280ee..58fa404 100644 --- a/src/server/routes/lightning-tournament-router.ts +++ b/src/server/routes/lightning-tournament-router.ts @@ -6,16 +6,21 @@ import { LightningTournamentController } from "../../modules/lightning-tournamen import { LightningRankingPostgresRepository } from "../../modules/lightning-tournaments/infrastructure/LightningRankingPostgresRepository"; import { UserPostgresRepository } from "../../modules/user/infrastructure/UserPostgresRepository"; import { config } from "src/config"; -import { Pino } from "src/shared/logger/infrastructure/Pino"; import { TournamentEnrollmentUseCase } from "src/modules/lightning-tournaments/application/TournamentEnrollmentUseCase"; import { TournamentGateway } from "src/modules/lightning-tournaments/infrastructure/TournamentGateway"; import { JWT } from "src/shared/JWT"; +import { Pino } from "src/shared/logger/infrastructure/Pino"; const logger = new Pino(); const repository = new LightningRankingPostgresRepository(); const userRepository = new UserPostgresRepository(); const tournamentRepository = new TournamentGateway(); -const updateRanking = new UpdateRankingUseCase(repository, userRepository, logger); +const updateRanking = new UpdateRankingUseCase( + repository, + userRepository, + config.tournaments.apiUrl, + logger +); const getRanking = new GetRankingUseCase(repository); const tournamentEnrollmentUseCase = new TournamentEnrollmentUseCase(userRepository, tournamentRepository); const jwt = new JWT(config.jwt) From 169bcb8b9dd40b4d8b81203ab9f379e56892228a Mon Sep 17 00:00:00 2001 From: Diango Gavidia Date: Fri, 21 Nov 2025 18:55:27 -0400 Subject: [PATCH 08/12] feat(evolution-api): use config for tournaments proxy & clean up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace hard‑coded process.env.TOURNAMENTS_API_URL with config.tournaments.apiUrl. - Delete obsolete setup‑admin.ts script. --- .../CreateTournamentProxyUseCase.ts | 1 + .../application/GetRankingUseCase.ts | 4 +- .../TournamentWithdrawalUseCase.ts | 21 +++ .../application/UpdateRankingUseCase.ts | 3 + .../domain/RankingWithUser.ts | 10 ++ .../domain/TournamentRanking.ts | 24 +++- .../domain/TournamentRankingRepository.ts | 3 +- .../domain/TournamentRepository.ts | 8 ++ .../LightningRankingPostgresRepository.ts | 43 ++++-- .../LightningTournamentController.ts | 18 ++- .../infrastructure/TournamentGateway.ts | 90 ++++++++++++ .../TournamentsProxyController.ts | 134 +++++++++++++++++- .../routes/lightning-tournament-router.ts | 3 + src/server/routes/tournaments-proxy-router.ts | 4 +- 14 files changed, 332 insertions(+), 34 deletions(-) create mode 100644 src/modules/lightning-tournaments/application/TournamentWithdrawalUseCase.ts create mode 100644 src/modules/lightning-tournaments/domain/RankingWithUser.ts diff --git a/src/modules/lightning-tournaments/application/CreateTournamentProxyUseCase.ts b/src/modules/lightning-tournaments/application/CreateTournamentProxyUseCase.ts index 1f11d2f..4f7bd61 100644 --- a/src/modules/lightning-tournaments/application/CreateTournamentProxyUseCase.ts +++ b/src/modules/lightning-tournaments/application/CreateTournamentProxyUseCase.ts @@ -42,6 +42,7 @@ export class CreateTournamentProxyUseCase { const tournamentData = { ...tournamentFields, webhookUrl: this.webhookUrl, + status: "PUBLISHED", metadata: banlist ? { banlist } : {} }; diff --git a/src/modules/lightning-tournaments/application/GetRankingUseCase.ts b/src/modules/lightning-tournaments/application/GetRankingUseCase.ts index f574d7f..3e62fdb 100644 --- a/src/modules/lightning-tournaments/application/GetRankingUseCase.ts +++ b/src/modules/lightning-tournaments/application/GetRankingUseCase.ts @@ -1,10 +1,10 @@ -import { TournamentRanking } from "../domain/TournamentRanking"; import { TournamentRankingRepository } from "../domain/TournamentRankingRepository"; +import { RankingWithUser } from "../domain/RankingWithUser"; export class GetRankingUseCase { constructor(private readonly repository: TournamentRankingRepository) { } - async execute(limit: number = 10): Promise { + async execute(limit: number = 10): Promise { return this.repository.getTopRankings(limit); } } diff --git a/src/modules/lightning-tournaments/application/TournamentWithdrawalUseCase.ts b/src/modules/lightning-tournaments/application/TournamentWithdrawalUseCase.ts new file mode 100644 index 0000000..478e978 --- /dev/null +++ b/src/modules/lightning-tournaments/application/TournamentWithdrawalUseCase.ts @@ -0,0 +1,21 @@ +import { UserRepository } from "src/modules/user/domain/UserRepository"; +import { NotFoundError } from "src/shared/errors/NotFoundError"; +import { TournamentRepository } from "../domain/TournamentRepository"; + +export class TournamentWithdrawalUseCase { + constructor(private readonly userRepository: UserRepository, private readonly tournamentRepository: TournamentRepository) { } + + async execute({ userId, tournamentId }: { userId: string; tournamentId: string }): Promise { + const user = await this.userRepository.findById(userId) + if (!user) { + throw new NotFoundError(`User with id: ${userId} not found`) + } + + if (!user.participantId) { + throw new Error("User is not a participant") + } + + await this.tournamentRepository.withdrawTournament(tournamentId, user.participantId); + + } +} diff --git a/src/modules/lightning-tournaments/application/UpdateRankingUseCase.ts b/src/modules/lightning-tournaments/application/UpdateRankingUseCase.ts index 089be14..dd08eea 100644 --- a/src/modules/lightning-tournaments/application/UpdateRankingUseCase.ts +++ b/src/modules/lightning-tournaments/application/UpdateRankingUseCase.ts @@ -2,6 +2,7 @@ import { UserRepository } from "../../user/domain/UserRepository"; import { TournamentRankingRepository } from "../domain/TournamentRankingRepository"; import { TournamentRanking } from "../domain/TournamentRanking"; import { Logger } from "src/shared/logger/domain/Logger"; +import { config } from "src/config"; // Fixed points distribution by position const POINTS_BY_POSITION: Record = { @@ -77,7 +78,9 @@ export class UpdateRankingUseCase { points, tournamentsWon: isWinner ? 1 : 0, tournamentsPlayed: 1, + season: config.season.toString(), }); + } else { userRanking = userRanking.addPoints(points); userRanking = userRanking.incrementTournamentsPlayed(); diff --git a/src/modules/lightning-tournaments/domain/RankingWithUser.ts b/src/modules/lightning-tournaments/domain/RankingWithUser.ts new file mode 100644 index 0000000..9e907c1 --- /dev/null +++ b/src/modules/lightning-tournaments/domain/RankingWithUser.ts @@ -0,0 +1,10 @@ +export interface RankingWithUser { + userId: string; + points: number; + tournamentsWon: number; + tournamentsPlayed: number; + user: { + username: string; + email: string; + } | null; +} diff --git a/src/modules/lightning-tournaments/domain/TournamentRanking.ts b/src/modules/lightning-tournaments/domain/TournamentRanking.ts index bc48b72..65555e7 100644 --- a/src/modules/lightning-tournaments/domain/TournamentRanking.ts +++ b/src/modules/lightning-tournaments/domain/TournamentRanking.ts @@ -11,7 +11,8 @@ export class TournamentRanking { private readonly userId: string, private readonly _points: number, private readonly _tournamentsWon: number, - private readonly _tournamentsPlayed: number + private readonly _tournamentsPlayed: number, + private readonly _season: string ) { } static createNew(props: { @@ -19,12 +20,14 @@ export class TournamentRanking { points: number; tournamentsWon: number; tournamentsPlayed: number; + season: string; }): TournamentRanking { return new TournamentRanking( props.userId, props.points, props.tournamentsWon, - props.tournamentsPlayed + props.tournamentsPlayed, + props.season, ); } @@ -33,12 +36,14 @@ export class TournamentRanking { points: number; tournamentsWon: number; tournamentsPlayed: number; + season: string; }): TournamentRanking { return new TournamentRanking( props.userId, props.points, props.tournamentsWon, - props.tournamentsPlayed + props.tournamentsPlayed, + props.season ); } @@ -47,7 +52,8 @@ export class TournamentRanking { this.userId, this._points + points, this._tournamentsWon, - this._tournamentsPlayed + this._tournamentsPlayed, + this._season ); } @@ -56,7 +62,8 @@ export class TournamentRanking { this.userId, this._points, this._tournamentsWon, - this._tournamentsPlayed + 1 + this._tournamentsPlayed + 1, + this._season ); } @@ -65,7 +72,8 @@ export class TournamentRanking { this.userId, this._points, this._tournamentsWon + 1, - this._tournamentsPlayed + this._tournamentsPlayed, + this._season ); } @@ -81,6 +89,10 @@ export class TournamentRanking { return this._tournamentsPlayed; } + get season(): string { + return this._season; + } + getUserId(): string { return this.userId; } diff --git a/src/modules/lightning-tournaments/domain/TournamentRankingRepository.ts b/src/modules/lightning-tournaments/domain/TournamentRankingRepository.ts index a0a94fc..dbf49c9 100644 --- a/src/modules/lightning-tournaments/domain/TournamentRankingRepository.ts +++ b/src/modules/lightning-tournaments/domain/TournamentRankingRepository.ts @@ -1,7 +1,8 @@ import { TournamentRanking } from "./TournamentRanking"; +import { RankingWithUser } from "./RankingWithUser"; export interface TournamentRankingRepository { findByUserId(userId: string): Promise; save(ranking: TournamentRanking): Promise; - getTopRankings(limit: number): Promise; + getTopRankings(limit: number): Promise; } diff --git a/src/modules/lightning-tournaments/domain/TournamentRepository.ts b/src/modules/lightning-tournaments/domain/TournamentRepository.ts index 4705dca..855d142 100644 --- a/src/modules/lightning-tournaments/domain/TournamentRepository.ts +++ b/src/modules/lightning-tournaments/domain/TournamentRepository.ts @@ -1,4 +1,12 @@ export interface TournamentRepository { createUserTournament({ displayName, email }: { displayName: string; email: string; }): Promise; enrollTournament({ tournamentId, participantId }: { tournamentId: string; participantId: string; }): Promise; + withdrawTournament(tournamentId: string, participantId: string): Promise; + editMatchResult(tournamentId: string, matchId: string, participants: Array<{ participantId: string; score: number; result?: string }>): Promise; + annulMatchResult(tournamentId: string, matchId: string): Promise; + publishTournament(tournamentId: string): Promise; + startTournament(tournamentId: string): Promise; + completeTournament(tournamentId: string): Promise; + cancelTournament(tournamentId: string): Promise; + confirmTournamentEntry(tournamentId: string, participantId: string): Promise; } \ No newline at end of file diff --git a/src/modules/lightning-tournaments/infrastructure/LightningRankingPostgresRepository.ts b/src/modules/lightning-tournaments/infrastructure/LightningRankingPostgresRepository.ts index 1b9c392..2785ef9 100644 --- a/src/modules/lightning-tournaments/infrastructure/LightningRankingPostgresRepository.ts +++ b/src/modules/lightning-tournaments/infrastructure/LightningRankingPostgresRepository.ts @@ -1,59 +1,72 @@ -import { config } from "src/config"; -import { dataSource } from "../../../evolution-types/src/data-source"; -import { LightningRankingEntity } from "../../../evolution-types/src/entities/LightningRankingEntity"; +import { dataSource } from "src/evolution-types/src/data-source"; +import { LightningRankingEntity } from "src/evolution-types/src/entities/LightningRankingEntity"; import { TournamentRanking } from "../domain/TournamentRanking"; import { TournamentRankingRepository } from "../domain/TournamentRankingRepository"; +import { RankingWithUser } from "../domain/RankingWithUser"; export class LightningRankingPostgresRepository implements TournamentRankingRepository { async findByUserId(userId: string): Promise { const repository = dataSource.getRepository(LightningRankingEntity); - const entity = await repository.findOne({ where: { userId } }); + const entity = await repository.findOne({ + where: { userId }, + relations: ["user"] + }); - if (!entity) return null; + if (!entity) { + return null; + } return TournamentRanking.fromPrimitives({ userId: entity.userId, points: entity.points, tournamentsWon: entity.tournamentsWon, tournamentsPlayed: entity.tournamentsPlayed, + season: entity.season, }); } async save(ranking: TournamentRanking): Promise { const repository = dataSource.getRepository(LightningRankingEntity); - const existing = await repository.findOne({ where: { userId: ranking.getUserId() } }); + const existingEntity = await repository.findOne({ + where: { userId: ranking.getUserId() } + }); - if (existing) { - existing.points = ranking.points; - existing.tournamentsWon = ranking.tournamentsWon; - existing.tournamentsPlayed = ranking.tournamentsPlayed; - await repository.save(existing); + if (existingEntity) { + existingEntity.points = ranking.points; + existingEntity.tournamentsWon = ranking.tournamentsWon; + existingEntity.tournamentsPlayed = ranking.tournamentsPlayed; + existingEntity.season = ranking.season; + await repository.save(existingEntity); } else { const newEntity = repository.create({ userId: ranking.getUserId(), points: ranking.points, tournamentsWon: ranking.tournamentsWon, tournamentsPlayed: ranking.tournamentsPlayed, - season: config.season.toString() + season: ranking.season, }); await repository.save(newEntity); } } - async getTopRankings(limit: number): Promise { + async getTopRankings(limit: number): Promise { const repository = dataSource.getRepository(LightningRankingEntity); - const entities = await repository.find({ + const rankings = await repository.find({ order: { points: "DESC" }, take: limit, relations: ["user"] }); - return entities.map(entity => TournamentRanking.fromPrimitives({ + return rankings.map(entity => ({ userId: entity.userId, points: entity.points, tournamentsWon: entity.tournamentsWon, tournamentsPlayed: entity.tournamentsPlayed, + user: entity.user ? { + username: entity.user.username, + email: entity.user.email + } : null })); } } diff --git a/src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts b/src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts index cbc2f53..8aaab09 100644 --- a/src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts +++ b/src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts @@ -4,6 +4,7 @@ import { UpdateRankingUseCase } from "../application/UpdateRankingUseCase"; import { GetRankingUseCase } from "../application/GetRankingUseCase"; import { CreateTournamentInput, CreateTournamentProxyUseCase } from "../application/CreateTournamentProxyUseCase"; import { TournamentEnrollmentUseCase } from "../application/TournamentEnrollmentUseCase"; +import { TournamentWithdrawalUseCase } from "../application/TournamentWithdrawalUseCase"; import { JWT } from "src/shared/JWT"; import { UserProfileRole } from "src/evolution-types/src/types/UserProfileRole"; import { UnauthorizedError } from "src/shared/errors/UnauthorizedError"; @@ -14,6 +15,7 @@ export class LightningTournamentController { private readonly getRanking: GetRankingUseCase, private readonly createTournament: CreateTournamentProxyUseCase, private readonly tournamentEnrollmentUseCase: TournamentEnrollmentUseCase, + private readonly tournamentWithdrawalUseCase: TournamentWithdrawalUseCase, private readonly jwt: JWT ) { } @@ -34,9 +36,9 @@ export class LightningTournamentController { }) }) .get("/ranking", async ({ query }) => { - const limit = query.limit ? parseInt(query.limit) : 10; - const ranking = await this.getRanking.execute(limit); - return ranking.map(r => r.toPrimitives()); + const limit = query.limit ? parseInt(query.limit as string) : 10; + const rankings = await this.getRanking.execute(limit); + return rankings; // Already plain objects with user data }) .post("/", async ({ body, bearer }) => { const { role } = this.jwt.decode(bearer as string) as { role: string }; @@ -56,6 +58,16 @@ export class LightningTournamentController { tournamentId: t.String(), }) }) + .post("/withdraw", async ({ body }) => { + const { userId, tournamentId } = body as { userId: string; tournamentId: string }; + await this.tournamentWithdrawalUseCase.execute({ userId, tournamentId }); + return { success: true }; + }, { + body: t.Object({ + userId: t.String(), + tournamentId: t.String(), + }) + }) ); } } diff --git a/src/modules/lightning-tournaments/infrastructure/TournamentGateway.ts b/src/modules/lightning-tournaments/infrastructure/TournamentGateway.ts index 7836d05..5c0738f 100644 --- a/src/modules/lightning-tournaments/infrastructure/TournamentGateway.ts +++ b/src/modules/lightning-tournaments/infrastructure/TournamentGateway.ts @@ -46,4 +46,94 @@ export class TournamentGateway implements TournamentRepository { return; } + async withdrawTournament(tournamentId: string, participantId: string): Promise { + const response = await fetch(`${config.tournaments.apiUrl}/tournaments/${tournamentId}/entries/${participantId}`, { + method: 'DELETE', + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to withdraw from tournament: ${response.status} ${text}`); + } + } + + async editMatchResult(tournamentId: string, matchId: string, participants: Array<{ participantId: string; score: number; result?: string }>): Promise { + const response = await fetch(`${config.tournaments.apiUrl}/tournaments/${tournamentId}/matches/${matchId}/result`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ participants }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to edit match result: ${response.status} ${text}`); + } + } + + async annulMatchResult(tournamentId: string, matchId: string): Promise { + const response = await fetch(`${config.tournaments.apiUrl}/tournaments/${tournamentId}/matches/${matchId}/result`, { + method: 'DELETE', + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to annul match result: ${response.status} ${text}`); + } + } + + async publishTournament(tournamentId: string): Promise { + const response = await fetch(`${config.tournaments.apiUrl}/tournaments/${tournamentId}/publish`, { + method: 'PUT', + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to publish tournament: ${response.status} ${text}`); + } + } + + async startTournament(tournamentId: string): Promise { + const response = await fetch(`${config.tournaments.apiUrl}/tournaments/${tournamentId}/start`, { + method: 'PUT', + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to start tournament: ${response.status} ${text}`); + } + } + + async completeTournament(tournamentId: string): Promise { + const response = await fetch(`${config.tournaments.apiUrl}/tournaments/${tournamentId}/complete`, { + method: 'PUT', + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to complete tournament: ${response.status} ${text}`); + } + } + + async cancelTournament(tournamentId: string): Promise { + const response = await fetch(`${config.tournaments.apiUrl}/tournaments/${tournamentId}/cancel`, { + method: 'PUT', + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to cancel tournament: ${response.status} ${text}`); + } + } + + async confirmTournamentEntry(tournamentId: string, participantId: string): Promise { + const response = await fetch(`${config.tournaments.apiUrl}/tournaments/${tournamentId}/entries/${participantId}/confirm`, { + method: 'PUT', + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to confirm tournament entry: ${response.status} ${text}`); + } + } + } \ No newline at end of file diff --git a/src/modules/lightning-tournaments/infrastructure/TournamentsProxyController.ts b/src/modules/lightning-tournaments/infrastructure/TournamentsProxyController.ts index cd23674..cae1f1f 100644 --- a/src/modules/lightning-tournaments/infrastructure/TournamentsProxyController.ts +++ b/src/modules/lightning-tournaments/infrastructure/TournamentsProxyController.ts @@ -1,14 +1,20 @@ import { Elysia, t } from "elysia"; import { bearer } from "@elysiajs/bearer"; import { JWT } from "src/shared/JWT"; -import { UserProfileRole } from "src/evolution-types/src/types/UserProfileRole"; import { UnauthorizedError } from "src/shared/errors/UnauthorizedError"; +import { UserProfileRole } from "src/evolution-types/src/types/UserProfileRole"; +import type { TournamentRepository } from "../domain/TournamentRepository"; +import { config } from "src/config"; export class TournamentsProxyController { + private readonly tournamentsApiUrl: string; + constructor( - private readonly tournamentsApiUrl: string, - private readonly jwt: JWT - ) { } + private readonly jwt: JWT, + private readonly tournamentRepository: TournamentRepository + ) { + this.tournamentsApiUrl = config.tournaments.apiUrl; + } routes(app: Elysia) { return app.group("/tournaments", (app) => @@ -35,13 +41,13 @@ export class TournamentsProxyController { return response.json(); }) // Bracket generation endpoint - .post("/:tournamentId/bracket/generate", async ({ params, bearer }) => { + .post("/:tournamentId/bracket/generate-full", async ({ params, bearer }) => { const { role } = this.jwt.decode(bearer as string) as { role: string }; if (role !== UserProfileRole.ADMIN) { throw new UnauthorizedError("You do not have permission to generate brackets"); } - const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/bracket/generate`, { + const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/bracket/generate-full`, { method: 'POST', }); @@ -52,6 +58,17 @@ export class TournamentsProxyController { return response.json(); }) + // Get bracket endpoint (includes participant displayNames) + .get("/:tournamentId/bracket", async ({ params }) => { + const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/bracket`); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to fetch bracket: ${response.status} ${text}`); + } + + return response.json(); + }) // Matches endpoints .get("/:tournamentId/matches", async ({ params }) => { @@ -90,6 +107,111 @@ export class TournamentsProxyController { })) }) }) + .put("/:tournamentId/matches/:matchId/result", async ({ params, body, bearer }) => { + const { role } = this.jwt.decode(bearer as string) as { role: string }; + if (role !== UserProfileRole.ADMIN) { + throw new UnauthorizedError("You do not have permission to edit match results"); + } + + const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/matches/${params.matchId}/result`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to edit match result: ${response.status} ${text}`); + } + + return response.json(); + }, { + body: t.Object({ + participants: t.Array(t.Object({ + participantId: t.String(), + score: t.Number(), + })) + }) + }) + .delete("/:tournamentId/matches/:matchId/result", async ({ params, bearer }) => { + const { role } = this.jwt.decode(bearer as string) as { role: string }; + if (role !== UserProfileRole.ADMIN) { + throw new UnauthorizedError("You do not have permission to annul match results"); + } + + const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/matches/${params.matchId}/result`, { + method: 'DELETE', + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to annul match result: ${response.status} ${text}`); + } + + return { message: "Match result annulled" }; + }) + .delete("/:tournamentId/participants/:participantId", async ({ params, bearer }) => { + const { role } = this.jwt.decode(bearer as string) as { role: string }; + if (role !== UserProfileRole.ADMIN) { + throw new UnauthorizedError("You do not have permission to remove participants"); + } + + const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/entries/${params.participantId}`, { + method: 'DELETE', + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to remove participant: ${response.status} ${text}`); + } + + return { message: "Participant removed" }; + }) + .put("/:tournamentId/publish", async ({ params, bearer }) => { + const { role } = this.jwt.decode(bearer as string) as { role: string }; + if (role !== UserProfileRole.ADMIN) { + throw new UnauthorizedError("You do not have permission to publish tournaments"); + } + + await this.tournamentRepository.publishTournament(params.tournamentId); + return { message: "Tournament published" }; + }) + .put("/:tournamentId/start", async ({ params, bearer }) => { + const { role } = this.jwt.decode(bearer as string) as { role: string }; + if (role !== UserProfileRole.ADMIN) { + throw new UnauthorizedError("You do not have permission to start tournaments"); + } + + await this.tournamentRepository.startTournament(params.tournamentId); + return { message: "Tournament started" }; + }) + .put("/:tournamentId/complete", async ({ params, bearer }) => { + const { role } = this.jwt.decode(bearer as string) as { role: string }; + if (role !== UserProfileRole.ADMIN) { + throw new UnauthorizedError("You do not have permission to complete tournaments"); + } + + await this.tournamentRepository.completeTournament(params.tournamentId); + return { message: "Tournament completed" }; + }) + .put("/:tournamentId/cancel", async ({ params, bearer }) => { + const { role } = this.jwt.decode(bearer as string) as { role: string }; + if (role !== UserProfileRole.ADMIN) { + throw new UnauthorizedError("You do not have permission to cancel tournaments"); + } + + await this.tournamentRepository.cancelTournament(params.tournamentId); + return { message: "Tournament cancelled" }; + }) + .put("/:tournamentId/entries/:participantId/confirm", async ({ params, bearer }) => { + const { role } = this.jwt.decode(bearer as string) as { role: string }; + if (role !== UserProfileRole.ADMIN) { + throw new UnauthorizedError("You do not have permission to confirm entries"); + } + + await this.tournamentRepository.confirmTournamentEntry(params.tournamentId, params.participantId); + return { message: "Entry confirmed" }; + }) ); } } diff --git a/src/server/routes/lightning-tournament-router.ts b/src/server/routes/lightning-tournament-router.ts index 58fa404..dd744ea 100644 --- a/src/server/routes/lightning-tournament-router.ts +++ b/src/server/routes/lightning-tournament-router.ts @@ -7,6 +7,7 @@ import { LightningRankingPostgresRepository } from "../../modules/lightning-tour import { UserPostgresRepository } from "../../modules/user/infrastructure/UserPostgresRepository"; import { config } from "src/config"; import { TournamentEnrollmentUseCase } from "src/modules/lightning-tournaments/application/TournamentEnrollmentUseCase"; +import { TournamentWithdrawalUseCase } from "src/modules/lightning-tournaments/application/TournamentWithdrawalUseCase"; import { TournamentGateway } from "src/modules/lightning-tournaments/infrastructure/TournamentGateway"; import { JWT } from "src/shared/JWT"; import { Pino } from "src/shared/logger/infrastructure/Pino"; @@ -23,6 +24,7 @@ const updateRanking = new UpdateRankingUseCase( ); const getRanking = new GetRankingUseCase(repository); const tournamentEnrollmentUseCase = new TournamentEnrollmentUseCase(userRepository, tournamentRepository); +const tournamentWithdrawalUseCase = new TournamentWithdrawalUseCase(userRepository, tournamentRepository); const jwt = new JWT(config.jwt) // Webhook URL will be dynamically generated in the controller based on request origin @@ -36,6 +38,7 @@ const controller = new LightningTournamentController( getRanking, createTournament, tournamentEnrollmentUseCase, + tournamentWithdrawalUseCase, jwt ); diff --git a/src/server/routes/tournaments-proxy-router.ts b/src/server/routes/tournaments-proxy-router.ts index a65b5a9..a852a3e 100644 --- a/src/server/routes/tournaments-proxy-router.ts +++ b/src/server/routes/tournaments-proxy-router.ts @@ -1,10 +1,12 @@ import { Elysia } from "elysia"; import { TournamentsProxyController } from "../../modules/lightning-tournaments/infrastructure/TournamentsProxyController"; +import { TournamentGateway } from "../../modules/lightning-tournaments/infrastructure/TournamentGateway"; import { config } from "src/config"; import { JWT } from "src/shared/JWT"; const jwt = new JWT(config.jwt); -const controller = new TournamentsProxyController(config.tournaments.apiUrl, jwt); +const tournamentGateway = new TournamentGateway(); +const controller = new TournamentsProxyController(jwt, tournamentGateway); export const tournamentsProxyRouter = new Elysia().use( controller.routes(new Elysia()) From 6ccf8cf2108f60802758285fedbfc45cef3777a0 Mon Sep 17 00:00:00 2001 From: Diango Gavidia Date: Mon, 24 Nov 2025 11:27:15 -0400 Subject: [PATCH 09/12] docs: added documentation to lightning tournament and proxy API endpoints --- .../LightningTournamentController.ts | 101 +++++ .../TournamentsProxyController.ts | 353 +++++++++++++++++- .../infrastructure/swagger-schemas.ts | 230 ++++++++++++ src/server/routes/ban-list-router.ts | 28 ++ src/server/routes/leaderboard-router.ts | 69 +++- src/server/routes/user-router.ts | 240 ++++++++++++ src/server/server.ts | 66 +++- 7 files changed, 1067 insertions(+), 20 deletions(-) create mode 100644 src/modules/lightning-tournaments/infrastructure/swagger-schemas.ts diff --git a/src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts b/src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts index 8aaab09..8771d7b 100644 --- a/src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts +++ b/src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts @@ -29,6 +29,21 @@ export class LightningTournamentController { await this.updateRanking.execute({ tournamentId }); return { success: true }; }, { + detail: { + tags: ['Lightning Tournaments'], + summary: 'Tournament completion webhook', + description: 'Webhook endpoint called when a tournament is completed to update rankings', + responses: { + 200: { + description: 'Rankings updated successfully', + content: { + 'application/json': { + example: { success: true } + } + } + } + } + }, body: t.Object({ winnerId: t.String(), tournamentId: t.String(), @@ -39,6 +54,34 @@ export class LightningTournamentController { const limit = query.limit ? parseInt(query.limit as string) : 10; const rankings = await this.getRanking.execute(limit); return rankings; // Already plain objects with user data + }, { + detail: { + tags: ['Lightning Tournaments'], + summary: 'Get lightning tournament ranking', + description: 'Retrieves the top players ranking for lightning tournaments', + responses: { + 200: { + description: 'Ranking retrieved successfully', + content: { + 'application/json': { + example: [ + { + userId: 'user-1', + username: 'Player1', + email: 'player1@example.com', + points: 150, + tournamentsWon: 5, + tournamentsPlayed: 20 + } + ] + } + } + } + } + }, + query: t.Object({ + limit: t.Optional(t.String()) + }) }) .post("/", async ({ body, bearer }) => { const { role } = this.jwt.decode(bearer as string) as { role: string }; @@ -47,12 +90,54 @@ export class LightningTournamentController { } const tournament = await this.createTournament.execute(body as CreateTournamentInput); return tournament; + }, { + detail: { + tags: ['Lightning Tournaments'], + summary: 'Create lightning tournament', + description: 'Creates a new lightning tournament. Requires admin privileges.', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'Tournament created successfully', + content: { + 'application/json': { + example: { + id: 'tournament-123', + name: 'Weekly Lightning', + discipline: 'Yu-Gi-Oh!', + format: 'Single Elimination', + status: 'PUBLISHED', + maxParticipants: 8 + } + } + } + }, + 401: { description: 'Unauthorized - Admin role required' } + } + } }) .post("/enroll", async ({ body }) => { const { userId, tournamentId } = body as { userId: string; tournamentId: string }; await this.tournamentEnrollmentUseCase.execute({ userId, tournamentId }); return { success: true }; }, { + detail: { + tags: ['Lightning Tournaments'], + summary: 'Enroll in tournament', + description: 'Enrolls a user in a lightning tournament', + responses: { + 200: { + description: 'User enrolled successfully', + content: { + 'application/json': { + example: { success: true } + } + } + }, + 404: { description: 'User or tournament not found' }, + 409: { description: 'User already enrolled or tournament full' } + } + }, body: t.Object({ userId: t.String(), tournamentId: t.String(), @@ -63,6 +148,22 @@ export class LightningTournamentController { await this.tournamentWithdrawalUseCase.execute({ userId, tournamentId }); return { success: true }; }, { + detail: { + tags: ['Lightning Tournaments'], + summary: 'Withdraw from tournament', + description: 'Withdraws a user from a lightning tournament', + responses: { + 200: { + description: 'User withdrawn successfully', + content: { + 'application/json': { + example: { success: true } + } + } + }, + 404: { description: 'User, tournament, or enrollment not found' } + } + }, body: t.Object({ userId: t.String(), tournamentId: t.String(), diff --git a/src/modules/lightning-tournaments/infrastructure/TournamentsProxyController.ts b/src/modules/lightning-tournaments/infrastructure/TournamentsProxyController.ts index cae1f1f..6396272 100644 --- a/src/modules/lightning-tournaments/infrastructure/TournamentsProxyController.ts +++ b/src/modules/lightning-tournaments/infrastructure/TournamentsProxyController.ts @@ -1,10 +1,12 @@ -import { Elysia, t } from "elysia"; +import { Elysia } from "elysia"; import { bearer } from "@elysiajs/bearer"; import { JWT } from "src/shared/JWT"; import { UnauthorizedError } from "src/shared/errors/UnauthorizedError"; import { UserProfileRole } from "src/evolution-types/src/types/UserProfileRole"; import type { TournamentRepository } from "../domain/TournamentRepository"; import { config } from "src/config"; +import { MatchResultRequestSchema } from "./swagger-schemas"; + export class TournamentsProxyController { private readonly tournamentsApiUrl: string; @@ -29,6 +31,27 @@ export class TournamentsProxyController { } return response.json(); + }, { + detail: { + tags: ['Players & Participants'], + summary: 'Get player by ID', + description: 'Retrieves player information from the tournaments service', + responses: { + 200: { + description: 'Player information retrieved successfully', + content: { + 'application/json': { + example: { + id: 'player-123', + displayName: 'JohnDoe', + userId: 'user-456' + } + } + } + }, + 404: { description: 'Player not found' } + } + } }) .get("/participants/:id", async ({ params }) => { const response = await fetch(`${this.tournamentsApiUrl}/participants/${params.id}`); @@ -39,6 +62,29 @@ export class TournamentsProxyController { } return response.json(); + }, { + detail: { + tags: ['Players & Participants'], + summary: 'Get participant by ID', + description: 'Retrieves participant information from the tournaments service', + responses: { + 200: { + description: 'Participant information retrieved successfully', + content: { + 'application/json': { + example: { + id: 'participant-789', + playerId: 'player-123', + tournamentId: 'tournament-001', + displayName: 'JohnDoe', + status: 'confirmed' + } + } + } + }, + 404: { description: 'Participant not found' } + } + } }) // Bracket generation endpoint .post("/:tournamentId/bracket/generate-full", async ({ params, bearer }) => { @@ -57,6 +103,45 @@ export class TournamentsProxyController { } return response.json(); + }, { + detail: { + tags: ['Bracket Management'], + summary: 'Generate full tournament bracket', + description: 'Generates the complete bracket structure for a tournament. Requires admin privileges.', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'Bracket generated successfully', + content: { + 'application/json': { + example: { + tournamentId: 'tournament-001', + rounds: [ + { + roundNumber: 1, + matches: [ + { + id: 'match-1', + tournamentId: 'tournament-001', + roundNumber: 1, + matchNumber: 1, + participants: [ + { participantId: 'p1', displayName: 'Player1', score: null, result: null }, + { participantId: 'p2', displayName: 'Player2', score: null, result: null } + ], + completedAt: null + } + ] + } + ] + } + } + } + }, + 401: { description: 'Unauthorized - Admin role required' }, + 404: { description: 'Tournament not found' } + } + } }) // Get bracket endpoint (includes participant displayNames) .get("/:tournamentId/bracket", async ({ params }) => { @@ -68,6 +153,42 @@ export class TournamentsProxyController { } return response.json(); + }, { + detail: { + tags: ['Bracket Management'], + summary: 'Get tournament bracket', + description: 'Retrieves the complete bracket structure including participant display names', + responses: { + 200: { + description: 'Bracket retrieved successfully', + content: { + 'application/json': { + example: { + tournamentId: 'tournament-001', + rounds: [ + { + roundNumber: 1, + matches: [ + { + id: 'match-1', + tournamentId: 'tournament-001', + roundNumber: 1, + participants: [ + { participantId: 'p1', displayName: 'Player1', score: 2, result: 'win' }, + { participantId: 'p2', displayName: 'Player2', score: 1, result: 'loss' } + ], + completedAt: '2025-11-24T10:00:00Z' + } + ] + } + ] + } + } + } + }, + 404: { description: 'Tournament or bracket not found' } + } + } }) // Matches endpoints @@ -80,6 +201,35 @@ export class TournamentsProxyController { } return response.json(); + }, { + detail: { + tags: ['Match Management'], + summary: 'Get all tournament matches', + description: 'Retrieves all matches for a specific tournament', + responses: { + 200: { + description: 'Matches retrieved successfully', + content: { + 'application/json': { + example: [ + { + id: 'match-1', + tournamentId: 'tournament-001', + roundNumber: 1, + matchNumber: 1, + participants: [ + { participantId: 'p1', displayName: 'Player1', score: 2, result: 'win' }, + { participantId: 'p2', displayName: 'Player2', score: 1, result: 'loss' } + ], + completedAt: '2025-11-24T10:00:00Z' + } + ] + } + } + }, + 404: { description: 'Tournament not found' } + } + } }) .post("/:tournamentId/matches/:matchId/result", async ({ params, body, bearer }) => { const { role } = this.jwt.decode(bearer as string) as { role: string }; @@ -100,12 +250,34 @@ export class TournamentsProxyController { return response.json(); }, { - body: t.Object({ - participants: t.Array(t.Object({ - participantId: t.String(), - score: t.Number(), - })) - }) + detail: { + tags: ['Match Management'], + summary: 'Record match result', + description: 'Records the result of a match with participant scores. Requires admin privileges.', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'Match result recorded successfully', + content: { + 'application/json': { + example: { + id: 'match-1', + tournamentId: 'tournament-001', + roundNumber: 1, + participants: [ + { participantId: 'p1', displayName: 'Player1', score: 2, result: 'win' }, + { participantId: 'p2', displayName: 'Player2', score: 1, result: 'loss' } + ], + completedAt: '2025-11-24T10:00:00Z' + } + } + } + }, + 401: { description: 'Unauthorized - Admin role required' }, + 404: { description: 'Tournament or match not found' } + } + }, + body: MatchResultRequestSchema }) .put("/:tournamentId/matches/:matchId/result", async ({ params, body, bearer }) => { const { role } = this.jwt.decode(bearer as string) as { role: string }; @@ -126,12 +298,34 @@ export class TournamentsProxyController { return response.json(); }, { - body: t.Object({ - participants: t.Array(t.Object({ - participantId: t.String(), - score: t.Number(), - })) - }) + detail: { + tags: ['Match Management'], + summary: 'Edit match result', + description: 'Updates an existing match result with new participant scores. Requires admin privileges.', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'Match result updated successfully', + content: { + 'application/json': { + example: { + id: 'match-1', + tournamentId: 'tournament-001', + roundNumber: 1, + participants: [ + { participantId: 'p1', displayName: 'Player1', score: 2, result: 'win' }, + { participantId: 'p2', displayName: 'Player2', score: 1, result: 'loss' } + ], + completedAt: '2025-11-24T10:00:00Z' + } + } + } + }, + 401: { description: 'Unauthorized - Admin role required' }, + 404: { description: 'Tournament or match not found' } + } + }, + body: MatchResultRequestSchema }) .delete("/:tournamentId/matches/:matchId/result", async ({ params, bearer }) => { const { role } = this.jwt.decode(bearer as string) as { role: string }; @@ -149,6 +343,25 @@ export class TournamentsProxyController { } return { message: "Match result annulled" }; + }, { + detail: { + tags: ['Match Management'], + summary: 'Annul match result', + description: 'Deletes/annuls a match result. Requires admin privileges.', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'Match result annulled successfully', + content: { + 'application/json': { + example: { message: 'Match result annulled' } + } + } + }, + 401: { description: 'Unauthorized - Admin role required' }, + 404: { description: 'Tournament or match not found' } + } + } }) .delete("/:tournamentId/participants/:participantId", async ({ params, bearer }) => { const { role } = this.jwt.decode(bearer as string) as { role: string }; @@ -166,6 +379,25 @@ export class TournamentsProxyController { } return { message: "Participant removed" }; + }, { + detail: { + tags: ['Participant Management'], + summary: 'Remove participant from tournament', + description: 'Removes a participant from a tournament. Requires admin privileges.', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'Participant removed successfully', + content: { + 'application/json': { + example: { message: 'Participant removed' } + } + } + }, + 401: { description: 'Unauthorized - Admin role required' }, + 404: { description: 'Tournament or participant not found' } + } + } }) .put("/:tournamentId/publish", async ({ params, bearer }) => { const { role } = this.jwt.decode(bearer as string) as { role: string }; @@ -175,6 +407,25 @@ export class TournamentsProxyController { await this.tournamentRepository.publishTournament(params.tournamentId); return { message: "Tournament published" }; + }, { + detail: { + tags: ['Tournament Lifecycle'], + summary: 'Publish tournament', + description: 'Changes tournament status to published. Requires admin privileges.', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'Tournament published successfully', + content: { + 'application/json': { + example: { message: 'Tournament published' } + } + } + }, + 401: { description: 'Unauthorized - Admin role required' }, + 404: { description: 'Tournament not found' } + } + } }) .put("/:tournamentId/start", async ({ params, bearer }) => { const { role } = this.jwt.decode(bearer as string) as { role: string }; @@ -184,6 +435,25 @@ export class TournamentsProxyController { await this.tournamentRepository.startTournament(params.tournamentId); return { message: "Tournament started" }; + }, { + detail: { + tags: ['Tournament Lifecycle'], + summary: 'Start tournament', + description: 'Changes tournament status to started/in-progress. Requires admin privileges.', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'Tournament started successfully', + content: { + 'application/json': { + example: { message: 'Tournament started' } + } + } + }, + 401: { description: 'Unauthorized - Admin role required' }, + 404: { description: 'Tournament not found' } + } + } }) .put("/:tournamentId/complete", async ({ params, bearer }) => { const { role } = this.jwt.decode(bearer as string) as { role: string }; @@ -193,6 +463,25 @@ export class TournamentsProxyController { await this.tournamentRepository.completeTournament(params.tournamentId); return { message: "Tournament completed" }; + }, { + detail: { + tags: ['Tournament Lifecycle'], + summary: 'Complete tournament', + description: 'Changes tournament status to completed. Requires admin privileges.', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'Tournament completed successfully', + content: { + 'application/json': { + example: { message: 'Tournament completed' } + } + } + }, + 401: { description: 'Unauthorized - Admin role required' }, + 404: { description: 'Tournament not found' } + } + } }) .put("/:tournamentId/cancel", async ({ params, bearer }) => { const { role } = this.jwt.decode(bearer as string) as { role: string }; @@ -202,6 +491,25 @@ export class TournamentsProxyController { await this.tournamentRepository.cancelTournament(params.tournamentId); return { message: "Tournament cancelled" }; + }, { + detail: { + tags: ['Tournament Lifecycle'], + summary: 'Cancel tournament', + description: 'Changes tournament status to cancelled. Requires admin privileges.', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'Tournament cancelled successfully', + content: { + 'application/json': { + example: { message: 'Tournament cancelled' } + } + } + }, + 401: { description: 'Unauthorized - Admin role required' }, + 404: { description: 'Tournament not found' } + } + } }) .put("/:tournamentId/entries/:participantId/confirm", async ({ params, bearer }) => { const { role } = this.jwt.decode(bearer as string) as { role: string }; @@ -211,6 +519,25 @@ export class TournamentsProxyController { await this.tournamentRepository.confirmTournamentEntry(params.tournamentId, params.participantId); return { message: "Entry confirmed" }; + }, { + detail: { + tags: ['Participant Management'], + summary: 'Confirm tournament entry', + description: 'Confirms a participant entry for a tournament. Requires admin privileges.', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'Entry confirmed successfully', + content: { + 'application/json': { + example: { message: 'Entry confirmed' } + } + } + }, + 401: { description: 'Unauthorized - Admin role required' }, + 404: { description: 'Tournament or participant not found' } + } + } }) ); } diff --git a/src/modules/lightning-tournaments/infrastructure/swagger-schemas.ts b/src/modules/lightning-tournaments/infrastructure/swagger-schemas.ts new file mode 100644 index 0000000..38ade7a --- /dev/null +++ b/src/modules/lightning-tournaments/infrastructure/swagger-schemas.ts @@ -0,0 +1,230 @@ +import { t } from "elysia"; + +// ============================================================================ +// Request Body Schemas +// ============================================================================ + +/** + * Schema for recording or editing match results + */ +export const MatchResultRequestSchema = t.Object({ + participants: t.Array(t.Object({ + participantId: t.String({ + description: 'Unique identifier of the participant', + examples: ['participant-123'] + }), + score: t.Number({ + description: 'Score achieved by the participant in the match', + examples: [2] + }), + }), { + description: 'Array of participants with their scores', + minItems: 2, + maxItems: 2 + }) +}, { + description: 'Match result data with participant scores', + examples: [{ + participants: [ + { participantId: 'participant-123', score: 2 }, + { participantId: 'participant-456', score: 1 } + ] + }] +}); + +// ============================================================================ +// Response Schemas +// ============================================================================ + +/** + * Generic message response schema + */ +export const MessageResponseSchema = t.Object({ + message: t.String({ + description: 'Response message', + examples: ['Tournament published'] + }) +}, { + description: 'Generic success message response' +}); + +/** + * Player information schema + */ +export const PlayerSchema = t.Object({ + id: t.String({ + description: 'Unique player identifier', + examples: ['player-123'] + }), + displayName: t.String({ + description: 'Display name of the player', + examples: ['JohnDoe'] + }), + userId: t.Optional(t.String({ + description: 'Associated user ID', + examples: ['user-456'] + })) +}, { + description: 'Player information' +}); + +/** + * Participant information schema + */ +export const ParticipantSchema = t.Object({ + id: t.String({ + description: 'Unique participant identifier', + examples: ['participant-789'] + }), + playerId: t.String({ + description: 'Associated player ID', + examples: ['player-123'] + }), + tournamentId: t.String({ + description: 'Tournament ID', + examples: ['tournament-001'] + }), + displayName: t.String({ + description: 'Display name for this tournament', + examples: ['JohnDoe'] + }), + status: t.String({ + description: 'Participant status', + examples: ['confirmed', 'pending', 'withdrawn'] + }) +}, { + description: 'Tournament participant information' +}); + +/** + * Match participant schema (within a match) + */ +export const MatchParticipantSchema = t.Object({ + participantId: t.String({ + description: 'Participant identifier', + examples: ['participant-123'] + }), + displayName: t.Optional(t.String({ + description: 'Participant display name', + examples: ['JohnDoe'] + })), + score: t.Union([t.Number(), t.Null()], { + description: 'Participant score (null if match not completed)', + examples: [2, null] + }), + result: t.Union([ + t.Literal('win'), + t.Literal('loss'), + t.Literal('draw'), + t.Null() + ], { + description: 'Match result for this participant', + examples: ['win', 'loss', null] + }) +}, { + description: 'Participant data within a match' +}); + +/** + * Match schema + */ +export const MatchSchema = t.Object({ + id: t.String({ + description: 'Unique match identifier', + examples: ['match-001'] + }), + tournamentId: t.String({ + description: 'Tournament identifier', + examples: ['tournament-001'] + }), + roundNumber: t.Number({ + description: 'Round number in the tournament', + examples: [1, 2, 3] + }), + matchNumber: t.Optional(t.Number({ + description: 'Match number within the round', + examples: [1] + })), + participants: t.Array(MatchParticipantSchema, { + description: 'Participants in this match', + minItems: 2, + maxItems: 2 + }), + completedAt: t.Union([t.String(), t.Null()], { + description: 'ISO timestamp when match was completed', + examples: ['2025-11-24T10:00:00Z', null] + }) +}, { + description: 'Match information' +}); + +/** + * Bracket round schema + */ +export const BracketRoundSchema = t.Object({ + roundNumber: t.Number({ + description: 'Round number', + examples: [1, 2, 3] + }), + matches: t.Array(MatchSchema, { + description: 'Matches in this round' + }) +}, { + description: 'Tournament bracket round' +}); + +/** + * Bracket schema + */ +export const BracketSchema = t.Object({ + tournamentId: t.String({ + description: 'Tournament identifier', + examples: ['tournament-001'] + }), + rounds: t.Array(BracketRoundSchema, { + description: 'All rounds in the bracket' + }) +}, { + description: 'Complete tournament bracket structure', + examples: [{ + tournamentId: 'tournament-001', + rounds: [ + { + roundNumber: 1, + matches: [ + { + id: 'match-1', + tournamentId: 'tournament-001', + roundNumber: 1, + matchNumber: 1, + participants: [ + { participantId: 'p1', displayName: 'Player1', score: null, result: null }, + { participantId: 'p2', displayName: 'Player2', score: null, result: null } + ], + completedAt: null + } + ] + } + ] + }] +}); + +/** + * Array of matches response + */ +export const MatchesArraySchema = t.Array(MatchSchema, { + description: 'Array of tournament matches', + examples: [[ + { + id: 'match-1', + tournamentId: 'tournament-001', + roundNumber: 1, + matchNumber: 1, + participants: [ + { participantId: 'p1', displayName: 'Player1', score: 2, result: 'win' }, + { participantId: 'p2', displayName: 'Player2', score: 1, result: 'loss' } + ], + completedAt: '2025-11-24T10:00:00Z' + } + ]] +}); diff --git a/src/server/routes/ban-list-router.ts b/src/server/routes/ban-list-router.ts index 0cd5aef..2b6e22c 100644 --- a/src/server/routes/ban-list-router.ts +++ b/src/server/routes/ban-list-router.ts @@ -12,6 +12,34 @@ export const banListRouter = new Elysia({ prefix: "ban-lists" }).get( return new BanListGetter(repository).get(query.season); }, { + detail: { + tags: ['Ban Lists'], + summary: 'Get ban lists', + description: 'Retrieves all ban lists for a specific season', + responses: { + 200: { + description: 'Ban lists retrieved successfully', + content: { + 'application/json': { + example: [ + { + id: 'banlist-1', + name: 'Edison', + season: 1, + description: 'Edison format ban list' + }, + { + id: 'banlist-2', + name: 'TCG', + season: 1, + description: 'TCG format ban list' + } + ] + } + } + } + } + }, query: t.Object({ season: t.Number({ default: config.season }), }), diff --git a/src/server/routes/leaderboard-router.ts b/src/server/routes/leaderboard-router.ts index 3b53de0..633ea7b 100644 --- a/src/server/routes/leaderboard-router.ts +++ b/src/server/routes/leaderboard-router.ts @@ -14,6 +14,36 @@ export const leaderboardRouter = new Elysia({ prefix: "/stats" }).get( return new UserStatsLeaderboardGetter(userStatsRepository).get(query); }, { + detail: { + tags: ['Leaderboard'], + summary: 'Get leaderboard', + description: 'Retrieves paginated leaderboard with player rankings for a specific season and ban list', + responses: { + 200: { + description: 'Leaderboard retrieved successfully', + content: { + 'application/json': { + example: { + data: [ + { + userId: 'user-1', + username: 'Player1', + email: 'player1@example.com', + points: 150, + tournamentsWon: 5, + tournamentsPlayed: 20, + rank: 1 + } + ], + total: 100, + page: 1, + limit: 100 + } + } + } + } + } + }, query: t.Object({ page: t.Number({ default: 1, minimum: 1 }), limit: t.Number({ default: 100, maximum: 100 }), @@ -22,9 +52,36 @@ export const leaderboardRouter = new Elysia({ prefix: "/stats" }).get( }), }, ) -.get( - "/player-of-the-week", - async () => { - return new GetBestPlayerOfLastCompletedWeek(userStatsRepository).get(); - }, -); + .get( + "/player-of-the-week", + async () => { + return new GetBestPlayerOfLastCompletedWeek(userStatsRepository).get(); + }, + { + detail: { + tags: ['Leaderboard'], + summary: 'Get player of the week', + description: 'Retrieves the best player from the last completed week', + responses: { + 200: { + description: 'Player of the week retrieved successfully', + content: { + 'application/json': { + example: { + userId: 'user-123', + username: 'TopPlayer', + email: 'topplayer@example.com', + points: 50, + tournamentsWon: 3, + tournamentsPlayed: 5, + weekNumber: 45, + year: 2025 + } + } + } + }, + 404: { description: 'No player found for last week' } + } + } + } + ); diff --git a/src/server/routes/user-router.ts b/src/server/routes/user-router.ts index d091778..0c2e4e1 100644 --- a/src/server/routes/user-router.ts +++ b/src/server/routes/user-router.ts @@ -47,6 +47,26 @@ export const userRouter = new Elysia({ prefix: "/users" }) return new UserRegister(userRepository, hash, logger, emailSender).register({ ...body, id }); }, { + detail: { + tags: ['Authentication'], + summary: 'Register new user', + description: 'Creates a new user account and sends verification email', + responses: { + 200: { + description: 'User registered successfully', + content: { + 'application/json': { + example: { + id: 'uuid-123', + username: 'player1', + email: 'player1@example.com' + } + } + } + }, + 409: { description: 'User already exists' } + } + }, body: t.Object({ username: t.String({ minLength: 1, maxLength: 14, pattern: '^.*\\S.*$' }), email: t.String({ minLength: 1, pattern: '^.*\\S.*$' }), @@ -59,6 +79,29 @@ export const userRouter = new Elysia({ prefix: "/users" }) return new UserAuth(userRepository, hash, jwt).login(body); }, { + detail: { + tags: ['Authentication'], + summary: 'User login', + description: 'Authenticates a user and returns a JWT token', + responses: { + 200: { + description: 'Login successful', + content: { + 'application/json': { + example: { + token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + user: { + id: 'user-123', + username: 'player1', + email: 'player1@example.com' + } + } + } + } + }, + 401: { description: 'Invalid credentials' } + } + }, body: t.Object({ email: t.String({ minLength: 1, pattern: '^.*\\S.*$' }), password: t.String({ minLength: 1, pattern: '^.*\\S.*$' }), @@ -72,6 +115,22 @@ export const userRouter = new Elysia({ prefix: "/users" }) return new UserForgotPassword(userRepository, emailSender, jwt, logger, baseUrl).forgotPassword(body); }, { + detail: { + tags: ['Authentication'], + summary: 'Request password reset', + description: 'Sends a password reset email to the user', + responses: { + 200: { + description: 'Reset email sent successfully', + content: { + 'application/json': { + example: { message: 'Password reset email sent' } + } + } + }, + 404: { description: 'User not found' } + } + }, body: t.Object({ email: t.String({ minLength: 1, pattern: '^.*\\S.*$' }), }), @@ -85,6 +144,22 @@ export const userRouter = new Elysia({ prefix: "/users" }) }); }, { + detail: { + tags: ['Authentication'], + summary: 'Validate reset token', + description: 'Validates a password reset token', + responses: { + 200: { + description: 'Token is valid', + content: { + 'application/json': { + example: { valid: true, email: 'user@example.com' } + } + } + }, + 401: { description: 'Invalid or expired token' } + } + }, query: t.Object({ token: t.String(), }), @@ -103,6 +178,23 @@ export const userRouter = new Elysia({ prefix: "/users" }) }); }, { + detail: { + tags: ['Authentication'], + summary: 'Reset password', + description: 'Resets user password using a valid reset token', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'Password reset successfully', + content: { + 'application/json': { + example: { message: 'Password reset successfully' } + } + } + }, + 401: { description: 'Invalid or expired token' } + } + }, body: t.Object({ password: t.String({ minLength: 4, maxLength: 4, pattern: '^.*\\S.*$' }), }), @@ -117,6 +209,29 @@ export const userRouter = new Elysia({ prefix: "/users" }) return new UserStatsFinder(userStatsRepository).find({ banListName, userId, season }); }, { + detail: { + tags: ['User Management'], + summary: 'Get user statistics', + description: 'Retrieves user statistics for a specific ban list and season', + responses: { + 200: { + description: 'Statistics retrieved successfully', + content: { + 'application/json': { + example: { + userId: 'user-123', + banListName: 'Global', + season: 1, + wins: 15, + losses: 5, + winRate: 0.75 + } + } + } + }, + 404: { description: 'User not found' } + } + }, query: t.Object({ banListName: t.String({ default: "Global" }), season: t.Number({ default: config.season }), @@ -137,6 +252,33 @@ export const userRouter = new Elysia({ prefix: "/users" }) return new MatchesGetter(matchRepository).get({ banListName, userId, limit, page, season }); }, { + detail: { + tags: ['User Management'], + summary: 'Get user matches', + description: 'Retrieves paginated match history for a user', + responses: { + 200: { + description: 'Matches retrieved successfully', + content: { + 'application/json': { + example: { + data: [ + { + id: 'match-1', + date: '2025-11-24T10:00:00Z', + opponent: 'Player2', + result: 'win' + } + ], + total: 50, + page: 1, + limit: 100 + } + } + } + } + } + }, query: t.Object({ page: t.Number({ default: 1, minimum: 1 }), limit: t.Number({ default: 100, maximum: 100 }), @@ -163,6 +305,23 @@ export const userRouter = new Elysia({ prefix: "/users" }) }); }, { + detail: { + tags: ['User Management'], + summary: 'Change password', + description: 'Changes the password for the authenticated user', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'Password changed successfully', + content: { + 'application/json': { + example: { message: 'Password updated successfully' } + } + } + }, + 401: { description: 'Invalid current password' } + } + }, body: t.Object({ password: t.String({ minLength: 1, pattern: '^.*\\S.*$' }), newPassword: t.String({ minLength: 4, maxLength: 4, pattern: '^.*\\S.*$' }), @@ -176,6 +335,23 @@ export const userRouter = new Elysia({ prefix: "/users" }) return new UserUsernameUpdater(userRepository).updateUsername({ ...(body as { username: string }), id }); }, { + detail: { + tags: ['User Management'], + summary: 'Change username', + description: 'Changes the username for the authenticated user', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'Username changed successfully', + content: { + 'application/json': { + example: { message: 'Username updated successfully' } + } + } + }, + 409: { description: 'Username already taken' } + } + }, body: t.Object({ username: t.String({ minLength: 1, maxLength: 14, pattern: '^.*\\S.*$' }), }), @@ -200,6 +376,24 @@ export const userRouter = new Elysia({ prefix: "/users" }) return { success: true }; }, { + detail: { + tags: ['User Bans'], + summary: 'Ban user', + description: 'Bans a user with a reason and optional expiration date. Requires admin privileges.', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'User banned successfully', + content: { + 'application/json': { + example: { success: true } + } + } + }, + 401: { description: 'Unauthorized - Admin role required' }, + 404: { description: 'User not found' } + } + }, params: t.Object({ userId: t.String() }), body: t.Object({ reason: t.String({ minLength: 1 }), @@ -218,6 +412,24 @@ export const userRouter = new Elysia({ prefix: "/users" }) return { success: true }; }, { + detail: { + tags: ['User Bans'], + summary: 'Unban user', + description: 'Removes an active ban from a user. Requires admin privileges.', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'User unbanned successfully', + content: { + 'application/json': { + example: { success: true } + } + } + }, + 401: { description: 'Unauthorized - Admin role required' }, + 404: { description: 'User or ban not found' } + } + }, params: t.Object({ userId: t.String() }), }, ) @@ -248,6 +460,34 @@ export const userRouter = new Elysia({ prefix: "/users" }) return { history: bans }; }, { + detail: { + tags: ['User Bans'], + summary: 'Get ban history', + description: 'Retrieves the complete ban history for a user. Requires admin privileges.', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'Ban history retrieved successfully', + content: { + 'application/json': { + example: { + history: [ + { + id: 'ban-123', + reason: 'Inappropriate behavior', + bannedAt: '2025-11-24T10:00:00Z', + unbannedAt: '2025-11-25T10:00:00Z', + isActive: false + } + ] + } + } + } + }, + 401: { description: 'Unauthorized - Admin role required' }, + 404: { description: 'User not found' } + } + }, params: t.Object({ userId: t.String() }), }, ); diff --git a/src/server/server.ts b/src/server/server.ts index ec33ed1..1a2d475 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -21,7 +21,71 @@ export class Server { constructor(logger: Logger) { this.app = new Elysia() .use(cors()) - .use(swagger()) + .use(swagger({ + documentation: { + info: { + title: 'Evolution API - Tournaments', + version: '1.0.0', + description: 'API for managing tournaments, matches, participants, and leaderboards' + }, + tags: [ + { + name: 'Authentication', + description: 'User authentication and registration endpoints' + }, + { + name: 'User Management', + description: 'User profile and account management' + }, + { + name: 'User Bans', + description: 'User ban management (Admin only)' + }, + { + name: 'Leaderboard', + description: 'Rankings and statistics endpoints' + }, + { + name: 'Ban Lists', + description: 'Game ban list information' + }, + { + name: 'Lightning Tournaments', + description: 'Lightning tournament management and enrollment' + }, + { + name: 'Players & Participants', + description: 'Endpoints for querying player and participant information' + }, + { + name: 'Bracket Management', + description: 'Endpoints for generating and retrieving tournament brackets' + }, + { + name: 'Match Management', + description: 'Endpoints for managing match results and match data' + }, + { + name: 'Tournament Lifecycle', + description: 'Endpoints for managing tournament state transitions (publish, start, complete, cancel)' + }, + { + name: 'Participant Management', + description: 'Endpoints for managing tournament participants and entries' + } + ], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'JWT token obtained from authentication endpoint' + } + } + } + } + })) .onError(({ error, set }) => { if (error instanceof ConflictError) { set.status = 409; From 79bd2ccccadcf01e5f219a2dfbc959db82a3fd9c Mon Sep 17 00:00:00 2001 From: Diango Gavidia Date: Mon, 24 Nov 2025 16:02:59 -0400 Subject: [PATCH 10/12] refactor: tournament module refactor --- .../LightningTournamentController.ts | 174 ------ .../CreateTournamentProxyUseCase.ts | 0 .../application/GetRankingUseCase.ts | 0 .../TournamentEnrollmentUseCase.ts | 0 .../TournamentWithdrawalUseCase.ts | 0 .../application/UpdateRankingUseCase.ts | 0 .../domain/RankingWithUser.ts | 0 .../domain/TournamentRanking.ts | 0 .../domain/TournamentRankingRepository.ts | 0 .../domain/TournamentRepository.ts | 0 .../infrastructure/TournamentController.ts} | 532 ++++++++---------- .../infrastructure/TournamentGateway.ts | 0 .../TournamentRankingPostgresRepository.ts} | 2 +- .../infrastructure/swagger-schemas.ts | 0 src/modules/user/application/UserRegister.ts | 3 +- ...rnament-router.ts => tournament-router.ts} | 22 +- src/server/routes/tournaments-proxy-router.ts | 13 - src/server/server.ts | 18 +- 18 files changed, 239 insertions(+), 525 deletions(-) delete mode 100644 src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts rename src/modules/{lightning-tournaments => tournaments}/application/CreateTournamentProxyUseCase.ts (100%) rename src/modules/{lightning-tournaments => tournaments}/application/GetRankingUseCase.ts (100%) rename src/modules/{lightning-tournaments => tournaments}/application/TournamentEnrollmentUseCase.ts (100%) rename src/modules/{lightning-tournaments => tournaments}/application/TournamentWithdrawalUseCase.ts (100%) rename src/modules/{lightning-tournaments => tournaments}/application/UpdateRankingUseCase.ts (100%) rename src/modules/{lightning-tournaments => tournaments}/domain/RankingWithUser.ts (100%) rename src/modules/{lightning-tournaments => tournaments}/domain/TournamentRanking.ts (100%) rename src/modules/{lightning-tournaments => tournaments}/domain/TournamentRankingRepository.ts (100%) rename src/modules/{lightning-tournaments => tournaments}/domain/TournamentRepository.ts (100%) rename src/modules/{lightning-tournaments/infrastructure/TournamentsProxyController.ts => tournaments/infrastructure/TournamentController.ts} (51%) rename src/modules/{lightning-tournaments => tournaments}/infrastructure/TournamentGateway.ts (100%) rename src/modules/{lightning-tournaments/infrastructure/LightningRankingPostgresRepository.ts => tournaments/infrastructure/TournamentRankingPostgresRepository.ts} (96%) rename src/modules/{lightning-tournaments => tournaments}/infrastructure/swagger-schemas.ts (100%) rename src/server/routes/{lightning-tournament-router.ts => tournament-router.ts} (52%) delete mode 100644 src/server/routes/tournaments-proxy-router.ts diff --git a/src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts b/src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts deleted file mode 100644 index 8771d7b..0000000 --- a/src/modules/lightning-tournaments/infrastructure/LightningTournamentController.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { Elysia, t } from "elysia"; -import { bearer } from "@elysiajs/bearer"; -import { UpdateRankingUseCase } from "../application/UpdateRankingUseCase"; -import { GetRankingUseCase } from "../application/GetRankingUseCase"; -import { CreateTournamentInput, CreateTournamentProxyUseCase } from "../application/CreateTournamentProxyUseCase"; -import { TournamentEnrollmentUseCase } from "../application/TournamentEnrollmentUseCase"; -import { TournamentWithdrawalUseCase } from "../application/TournamentWithdrawalUseCase"; -import { JWT } from "src/shared/JWT"; -import { UserProfileRole } from "src/evolution-types/src/types/UserProfileRole"; -import { UnauthorizedError } from "src/shared/errors/UnauthorizedError"; - -export class LightningTournamentController { - constructor( - private readonly updateRanking: UpdateRankingUseCase, - private readonly getRanking: GetRankingUseCase, - private readonly createTournament: CreateTournamentProxyUseCase, - private readonly tournamentEnrollmentUseCase: TournamentEnrollmentUseCase, - private readonly tournamentWithdrawalUseCase: TournamentWithdrawalUseCase, - private readonly jwt: JWT - ) { } - - routes(app: Elysia) { - return app.group("/lightning-tournaments", (app) => - app - .use(bearer()) - .post("/webhook", async ({ body }) => { - const { tournamentId } = body as { tournamentId: string; winnerId: string; completedAt: string }; - // Process all participants' rankings based on final positions - await this.updateRanking.execute({ tournamentId }); - return { success: true }; - }, { - detail: { - tags: ['Lightning Tournaments'], - summary: 'Tournament completion webhook', - description: 'Webhook endpoint called when a tournament is completed to update rankings', - responses: { - 200: { - description: 'Rankings updated successfully', - content: { - 'application/json': { - example: { success: true } - } - } - } - } - }, - body: t.Object({ - winnerId: t.String(), - tournamentId: t.String(), - completedAt: t.String(), - }) - }) - .get("/ranking", async ({ query }) => { - const limit = query.limit ? parseInt(query.limit as string) : 10; - const rankings = await this.getRanking.execute(limit); - return rankings; // Already plain objects with user data - }, { - detail: { - tags: ['Lightning Tournaments'], - summary: 'Get lightning tournament ranking', - description: 'Retrieves the top players ranking for lightning tournaments', - responses: { - 200: { - description: 'Ranking retrieved successfully', - content: { - 'application/json': { - example: [ - { - userId: 'user-1', - username: 'Player1', - email: 'player1@example.com', - points: 150, - tournamentsWon: 5, - tournamentsPlayed: 20 - } - ] - } - } - } - } - }, - query: t.Object({ - limit: t.Optional(t.String()) - }) - }) - .post("/", async ({ body, bearer }) => { - const { role } = this.jwt.decode(bearer as string) as { role: string }; - if (role !== UserProfileRole.ADMIN) { - throw new UnauthorizedError("You do not have permission to create tournaments"); - } - const tournament = await this.createTournament.execute(body as CreateTournamentInput); - return tournament; - }, { - detail: { - tags: ['Lightning Tournaments'], - summary: 'Create lightning tournament', - description: 'Creates a new lightning tournament. Requires admin privileges.', - security: [{ bearerAuth: [] }], - responses: { - 200: { - description: 'Tournament created successfully', - content: { - 'application/json': { - example: { - id: 'tournament-123', - name: 'Weekly Lightning', - discipline: 'Yu-Gi-Oh!', - format: 'Single Elimination', - status: 'PUBLISHED', - maxParticipants: 8 - } - } - } - }, - 401: { description: 'Unauthorized - Admin role required' } - } - } - }) - .post("/enroll", async ({ body }) => { - const { userId, tournamentId } = body as { userId: string; tournamentId: string }; - await this.tournamentEnrollmentUseCase.execute({ userId, tournamentId }); - return { success: true }; - }, { - detail: { - tags: ['Lightning Tournaments'], - summary: 'Enroll in tournament', - description: 'Enrolls a user in a lightning tournament', - responses: { - 200: { - description: 'User enrolled successfully', - content: { - 'application/json': { - example: { success: true } - } - } - }, - 404: { description: 'User or tournament not found' }, - 409: { description: 'User already enrolled or tournament full' } - } - }, - body: t.Object({ - userId: t.String(), - tournamentId: t.String(), - }) - }) - .post("/withdraw", async ({ body }) => { - const { userId, tournamentId } = body as { userId: string; tournamentId: string }; - await this.tournamentWithdrawalUseCase.execute({ userId, tournamentId }); - return { success: true }; - }, { - detail: { - tags: ['Lightning Tournaments'], - summary: 'Withdraw from tournament', - description: 'Withdraws a user from a lightning tournament', - responses: { - 200: { - description: 'User withdrawn successfully', - content: { - 'application/json': { - example: { success: true } - } - } - }, - 404: { description: 'User, tournament, or enrollment not found' } - } - }, - body: t.Object({ - userId: t.String(), - tournamentId: t.String(), - }) - }) - ); - } -} diff --git a/src/modules/lightning-tournaments/application/CreateTournamentProxyUseCase.ts b/src/modules/tournaments/application/CreateTournamentProxyUseCase.ts similarity index 100% rename from src/modules/lightning-tournaments/application/CreateTournamentProxyUseCase.ts rename to src/modules/tournaments/application/CreateTournamentProxyUseCase.ts diff --git a/src/modules/lightning-tournaments/application/GetRankingUseCase.ts b/src/modules/tournaments/application/GetRankingUseCase.ts similarity index 100% rename from src/modules/lightning-tournaments/application/GetRankingUseCase.ts rename to src/modules/tournaments/application/GetRankingUseCase.ts diff --git a/src/modules/lightning-tournaments/application/TournamentEnrollmentUseCase.ts b/src/modules/tournaments/application/TournamentEnrollmentUseCase.ts similarity index 100% rename from src/modules/lightning-tournaments/application/TournamentEnrollmentUseCase.ts rename to src/modules/tournaments/application/TournamentEnrollmentUseCase.ts diff --git a/src/modules/lightning-tournaments/application/TournamentWithdrawalUseCase.ts b/src/modules/tournaments/application/TournamentWithdrawalUseCase.ts similarity index 100% rename from src/modules/lightning-tournaments/application/TournamentWithdrawalUseCase.ts rename to src/modules/tournaments/application/TournamentWithdrawalUseCase.ts diff --git a/src/modules/lightning-tournaments/application/UpdateRankingUseCase.ts b/src/modules/tournaments/application/UpdateRankingUseCase.ts similarity index 100% rename from src/modules/lightning-tournaments/application/UpdateRankingUseCase.ts rename to src/modules/tournaments/application/UpdateRankingUseCase.ts diff --git a/src/modules/lightning-tournaments/domain/RankingWithUser.ts b/src/modules/tournaments/domain/RankingWithUser.ts similarity index 100% rename from src/modules/lightning-tournaments/domain/RankingWithUser.ts rename to src/modules/tournaments/domain/RankingWithUser.ts diff --git a/src/modules/lightning-tournaments/domain/TournamentRanking.ts b/src/modules/tournaments/domain/TournamentRanking.ts similarity index 100% rename from src/modules/lightning-tournaments/domain/TournamentRanking.ts rename to src/modules/tournaments/domain/TournamentRanking.ts diff --git a/src/modules/lightning-tournaments/domain/TournamentRankingRepository.ts b/src/modules/tournaments/domain/TournamentRankingRepository.ts similarity index 100% rename from src/modules/lightning-tournaments/domain/TournamentRankingRepository.ts rename to src/modules/tournaments/domain/TournamentRankingRepository.ts diff --git a/src/modules/lightning-tournaments/domain/TournamentRepository.ts b/src/modules/tournaments/domain/TournamentRepository.ts similarity index 100% rename from src/modules/lightning-tournaments/domain/TournamentRepository.ts rename to src/modules/tournaments/domain/TournamentRepository.ts diff --git a/src/modules/lightning-tournaments/infrastructure/TournamentsProxyController.ts b/src/modules/tournaments/infrastructure/TournamentController.ts similarity index 51% rename from src/modules/lightning-tournaments/infrastructure/TournamentsProxyController.ts rename to src/modules/tournaments/infrastructure/TournamentController.ts index 6396272..6a61ad9 100644 --- a/src/modules/lightning-tournaments/infrastructure/TournamentsProxyController.ts +++ b/src/modules/tournaments/infrastructure/TournamentController.ts @@ -1,149 +1,231 @@ -import { Elysia } from "elysia"; +import { Elysia, t } from "elysia"; import { bearer } from "@elysiajs/bearer"; +import { UpdateRankingUseCase } from "../application/UpdateRankingUseCase"; +import { GetRankingUseCase } from "../application/GetRankingUseCase"; +import { CreateTournamentInput, CreateTournamentProxyUseCase } from "../application/CreateTournamentProxyUseCase"; +import { TournamentEnrollmentUseCase } from "../application/TournamentEnrollmentUseCase"; +import { TournamentWithdrawalUseCase } from "../application/TournamentWithdrawalUseCase"; import { JWT } from "src/shared/JWT"; -import { UnauthorizedError } from "src/shared/errors/UnauthorizedError"; import { UserProfileRole } from "src/evolution-types/src/types/UserProfileRole"; -import type { TournamentRepository } from "../domain/TournamentRepository"; +import { UnauthorizedError } from "src/shared/errors/UnauthorizedError"; import { config } from "src/config"; import { MatchResultRequestSchema } from "./swagger-schemas"; - -export class TournamentsProxyController { +export class TournamentController { private readonly tournamentsApiUrl: string; constructor( - private readonly jwt: JWT, - private readonly tournamentRepository: TournamentRepository + private readonly updateRanking: UpdateRankingUseCase, + private readonly getRanking: GetRankingUseCase, + private readonly createTournament: CreateTournamentProxyUseCase, + private readonly tournamentEnrollmentUseCase: TournamentEnrollmentUseCase, + private readonly tournamentWithdrawalUseCase: TournamentWithdrawalUseCase, + private readonly jwt: JWT ) { this.tournamentsApiUrl = config.tournaments.apiUrl; + } routes(app: Elysia) { return app.group("/tournaments", (app) => app .use(bearer()) - .get("/players/:id", async ({ params }) => { - const response = await fetch(`${this.tournamentsApiUrl}/players/${params.id}`); + .get("/", async () => { + const response = await fetch(`${this.tournamentsApiUrl}/tournaments`); if (!response.ok) { const text = await response.text(); - throw new Error(`Failed to get player: ${response.status} ${text}`); + throw new Error(`Failed to get tournaments: ${response.status} ${text}`); } return response.json(); }, { detail: { - tags: ['Players & Participants'], - summary: 'Get player by ID', - description: 'Retrieves player information from the tournaments service', + tags: ['Lightning Tournaments'], + summary: 'Get all tournaments', + description: 'Retrieves a list of all tournaments from the tournaments service', responses: { 200: { - description: 'Player information retrieved successfully', + description: 'Tournaments retrieved successfully', content: { 'application/json': { - example: { - id: 'player-123', - displayName: 'JohnDoe', - userId: 'user-456' - } + example: [ + { + id: 'tournament-001', + name: 'Tournament 1', + status: 'open' + }, + { + id: 'tournament-002', + name: 'Tournament 2', + status: 'closed' + } + ] } } - }, - 404: { description: 'Player not found' } + } } } }) - .get("/participants/:id", async ({ params }) => { - const response = await fetch(`${this.tournamentsApiUrl}/participants/${params.id}`); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Failed to get participant: ${response.status} ${text}`); - } - - return response.json(); + .post("/webhook", async ({ body }) => { + const { tournamentId } = body as { tournamentId: string; winnerId: string; completedAt: string }; + // Process all participants' rankings based on final positions + await this.updateRanking.execute({ tournamentId }); + return { success: true }; }, { detail: { - tags: ['Players & Participants'], - summary: 'Get participant by ID', - description: 'Retrieves participant information from the tournaments service', + tags: ['Tournaments'], + summary: 'Tournament completion webhook', + description: 'Webhook endpoint called when a tournament is completed to update rankings', responses: { 200: { - description: 'Participant information retrieved successfully', + description: 'Rankings updated successfully', content: { 'application/json': { - example: { - id: 'participant-789', - playerId: 'player-123', - tournamentId: 'tournament-001', - displayName: 'JohnDoe', - status: 'confirmed' - } + example: { success: true } } } - }, - 404: { description: 'Participant not found' } + } } - } + }, + body: t.Object({ + winnerId: t.String(), + tournamentId: t.String(), + completedAt: t.String(), + }) + }) + .get("/ranking", async ({ query }) => { + const limit = query.limit ? parseInt(query.limit as string) : 10; + const rankings = await this.getRanking.execute(limit); + return rankings; // Already plain objects with user data + }, { + detail: { + tags: ['Lightning Tournaments'], + summary: 'Get lightning tournament ranking', + description: 'Retrieves the top players ranking for lightning tournaments', + responses: { + 200: { + description: 'Ranking retrieved successfully', + content: { + 'application/json': { + example: [ + { + userId: 'user-1', + username: 'Player1', + email: 'player1@example.com', + points: 150, + tournamentsWon: 5, + tournamentsPlayed: 20 + } + ] + } + } + } + } + }, + query: t.Object({ + limit: t.Optional(t.String()) + }) }) - // Bracket generation endpoint - .post("/:tournamentId/bracket/generate-full", async ({ params, bearer }) => { + .post("/", async ({ body, bearer }) => { const { role } = this.jwt.decode(bearer as string) as { role: string }; if (role !== UserProfileRole.ADMIN) { - throw new UnauthorizedError("You do not have permission to generate brackets"); - } - - const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/bracket/generate-full`, { - method: 'POST', - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Failed to generate bracket: ${response.status} ${text}`); + throw new UnauthorizedError("You do not have permission to create tournaments"); } - - return response.json(); + const tournament = await this.createTournament.execute(body as CreateTournamentInput); + return tournament; }, { detail: { - tags: ['Bracket Management'], - summary: 'Generate full tournament bracket', - description: 'Generates the complete bracket structure for a tournament. Requires admin privileges.', + tags: ['Lightning Tournaments'], + summary: 'Create lightning tournament', + description: 'Creates a new lightning tournament. Requires admin privileges.', security: [{ bearerAuth: [] }], responses: { 200: { - description: 'Bracket generated successfully', + description: 'Tournament created successfully', content: { 'application/json': { example: { - tournamentId: 'tournament-001', - rounds: [ - { - roundNumber: 1, - matches: [ - { - id: 'match-1', - tournamentId: 'tournament-001', - roundNumber: 1, - matchNumber: 1, - participants: [ - { participantId: 'p1', displayName: 'Player1', score: null, result: null }, - { participantId: 'p2', displayName: 'Player2', score: null, result: null } - ], - completedAt: null - } - ] - } - ] + id: 'tournament-123', + name: 'Weekly Lightning', + discipline: 'Yu-Gi-Oh!', + format: 'Single Elimination', + status: 'PUBLISHED', + participantType: 'SINGLE', + allowMixedParticipants: false, + maxParticipants: 8, + description: 'Weekly Lightning Tournament', + startAt: '2025-11-24T11:33:08-04:00', + endAt: '2025-11-24T11:33:08-04:00', + location: 'Online', + banlist: 'TCG', } } } }, - 401: { description: 'Unauthorized - Admin role required' }, - 404: { description: 'Tournament not found' } + 401: { description: 'Unauthorized - Admin role required' } } - } + }, + body: t.Object({ + name: t.String({ minLength: 1 }), + discipline: t.String({ minLength: 1 }), + format: t.String({ minLength: 1 }), + status: t.String({ minLength: 1 }), + participantType: t.String({ minLength: 1 }), + allowMixedParticipants: t.Boolean(), + maxParticipants: t.Number({ minimum: 1 }), + description: t.Optional(t.String()), + startAt: t.Optional(t.String()), + endAt: t.Optional(t.String()), + location: t.Optional(t.String()), + banlist: t.Optional(t.String()), + }) + }) + .post("/:tournamentId/enroll", async ({ params, bearer }) => { + const { id } = this.jwt.decode(bearer as string) as { id: string }; + await this.tournamentEnrollmentUseCase.execute({ userId: id, tournamentId: params.tournamentId }); + return { success: true }; + }, { + detail: { + tags: ['Lightning Tournaments'], + summary: 'Enroll in tournament', + description: 'Enrolls a user in a lightning tournament', + responses: { + 200: { + description: 'User enrolled successfully', + content: { + 'application/json': { + example: { success: true } + } + } + }, + 404: { description: 'User or tournament not found' }, + 409: { description: 'User already enrolled or tournament full' } + } + }, + }) + .post("/:tournamentId/withdraw", async ({ params, bearer }) => { + const { id } = this.jwt.decode(bearer as string) as { id: string }; + await this.tournamentWithdrawalUseCase.execute({ userId: id, tournamentId: params.tournamentId }); + return { success: true }; + }, { + detail: { + tags: ['Lightning Tournaments'], + summary: 'Withdraw from tournament', + description: 'Withdraws a user from a lightning tournament', + responses: { + 200: { + description: 'User withdrawn successfully', + content: { + 'application/json': { + example: { success: true } + } + } + }, + 404: { description: 'User, tournament, or enrollment not found' } + } + }, }) - // Get bracket endpoint (includes participant displayNames) .get("/:tournamentId/bracket", async ({ params }) => { const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/bracket`); @@ -190,122 +272,89 @@ export class TournamentsProxyController { } } }) - - // Matches endpoints - .get("/:tournamentId/matches", async ({ params }) => { - const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/matches`); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Failed to get matches: ${response.status} ${text}`); - } - - return response.json(); - }, { - detail: { - tags: ['Match Management'], - summary: 'Get all tournament matches', - description: 'Retrieves all matches for a specific tournament', - responses: { - 200: { - description: 'Matches retrieved successfully', - content: { - 'application/json': { - example: [ - { - id: 'match-1', - tournamentId: 'tournament-001', - roundNumber: 1, - matchNumber: 1, - participants: [ - { participantId: 'p1', displayName: 'Player1', score: 2, result: 'win' }, - { participantId: 'p2', displayName: 'Player2', score: 1, result: 'loss' } - ], - completedAt: '2025-11-24T10:00:00Z' - } - ] - } - } - }, - 404: { description: 'Tournament not found' } - } - } - }) - .post("/:tournamentId/matches/:matchId/result", async ({ params, body, bearer }) => { + .post("/:tournamentId/bracket", async ({ params, bearer }) => { const { role } = this.jwt.decode(bearer as string) as { role: string }; if (role !== UserProfileRole.ADMIN) { - throw new UnauthorizedError("You do not have permission to record match results"); + throw new UnauthorizedError("You do not have permission to generate brackets"); } - const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/matches/${params.matchId}/result`, { + const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/bracket/generate-full`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), }); if (!response.ok) { const text = await response.text(); - throw new Error(`Failed to record match result: ${response.status} ${text}`); + throw new Error(`Failed to generate bracket: ${response.status} ${text}`); } return response.json(); }, { detail: { - tags: ['Match Management'], - summary: 'Record match result', - description: 'Records the result of a match with participant scores. Requires admin privileges.', + tags: ['Bracket Management'], + summary: 'Generate full tournament bracket', + description: 'Generates the complete bracket structure for a tournament. Requires admin privileges.', security: [{ bearerAuth: [] }], responses: { 200: { - description: 'Match result recorded successfully', + description: 'Bracket generated successfully', content: { 'application/json': { example: { - id: 'match-1', tournamentId: 'tournament-001', - roundNumber: 1, - participants: [ - { participantId: 'p1', displayName: 'Player1', score: 2, result: 'win' }, - { participantId: 'p2', displayName: 'Player2', score: 1, result: 'loss' } - ], - completedAt: '2025-11-24T10:00:00Z' + rounds: [ + { + roundNumber: 1, + matches: [ + { + id: 'match-1', + tournamentId: 'tournament-001', + roundNumber: 1, + matchNumber: 1, + participants: [ + { participantId: 'p1', displayName: 'Player1', score: null, result: null }, + { participantId: 'p2', displayName: 'Player2', score: null, result: null } + ], + completedAt: null + } + ] + } + ] } } } }, 401: { description: 'Unauthorized - Admin role required' }, - 404: { description: 'Tournament or match not found' } + 404: { description: 'Tournament not found' } } - }, - body: MatchResultRequestSchema + } }) - .put("/:tournamentId/matches/:matchId/result", async ({ params, body, bearer }) => { + .post("/:tournamentId/matches/:matchId/result", async ({ params, body, bearer }) => { const { role } = this.jwt.decode(bearer as string) as { role: string }; if (role !== UserProfileRole.ADMIN) { - throw new UnauthorizedError("You do not have permission to edit match results"); + throw new UnauthorizedError("You do not have permission to record match results"); } const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/matches/${params.matchId}/result`, { - method: 'PUT', + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!response.ok) { const text = await response.text(); - throw new Error(`Failed to edit match result: ${response.status} ${text}`); + throw new Error(`Failed to record match result: ${response.status} ${text}`); } return response.json(); }, { detail: { tags: ['Match Management'], - summary: 'Edit match result', - description: 'Updates an existing match result with new participant scores. Requires admin privileges.', + summary: 'Record match result', + description: 'Records the result of a match with participant scores. Requires admin privileges.', security: [{ bearerAuth: [] }], responses: { 200: { - description: 'Match result updated successfully', + description: 'Match result recorded successfully', content: { 'application/json': { example: { @@ -363,182 +412,43 @@ export class TournamentsProxyController { } } }) - .delete("/:tournamentId/participants/:participantId", async ({ params, bearer }) => { - const { role } = this.jwt.decode(bearer as string) as { role: string }; - if (role !== UserProfileRole.ADMIN) { - throw new UnauthorizedError("You do not have permission to remove participants"); - } - - const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/entries/${params.participantId}`, { - method: 'DELETE', - }); + .get("/:tournamentId/entries", async ({ params }) => { + const response = await fetch(`${this.tournamentsApiUrl}/tournaments/${params.tournamentId}/entries`); if (!response.ok) { const text = await response.text(); - throw new Error(`Failed to remove participant: ${response.status} ${text}`); - } - - return { message: "Participant removed" }; - }, { - detail: { - tags: ['Participant Management'], - summary: 'Remove participant from tournament', - description: 'Removes a participant from a tournament. Requires admin privileges.', - security: [{ bearerAuth: [] }], - responses: { - 200: { - description: 'Participant removed successfully', - content: { - 'application/json': { - example: { message: 'Participant removed' } - } - } - }, - 401: { description: 'Unauthorized - Admin role required' }, - 404: { description: 'Tournament or participant not found' } - } - } - }) - .put("/:tournamentId/publish", async ({ params, bearer }) => { - const { role } = this.jwt.decode(bearer as string) as { role: string }; - if (role !== UserProfileRole.ADMIN) { - throw new UnauthorizedError("You do not have permission to publish tournaments"); - } - - await this.tournamentRepository.publishTournament(params.tournamentId); - return { message: "Tournament published" }; - }, { - detail: { - tags: ['Tournament Lifecycle'], - summary: 'Publish tournament', - description: 'Changes tournament status to published. Requires admin privileges.', - security: [{ bearerAuth: [] }], - responses: { - 200: { - description: 'Tournament published successfully', - content: { - 'application/json': { - example: { message: 'Tournament published' } - } - } - }, - 401: { description: 'Unauthorized - Admin role required' }, - 404: { description: 'Tournament not found' } - } - } - }) - .put("/:tournamentId/start", async ({ params, bearer }) => { - const { role } = this.jwt.decode(bearer as string) as { role: string }; - if (role !== UserProfileRole.ADMIN) { - throw new UnauthorizedError("You do not have permission to start tournaments"); - } - - await this.tournamentRepository.startTournament(params.tournamentId); - return { message: "Tournament started" }; - }, { - detail: { - tags: ['Tournament Lifecycle'], - summary: 'Start tournament', - description: 'Changes tournament status to started/in-progress. Requires admin privileges.', - security: [{ bearerAuth: [] }], - responses: { - 200: { - description: 'Tournament started successfully', - content: { - 'application/json': { - example: { message: 'Tournament started' } - } - } - }, - 401: { description: 'Unauthorized - Admin role required' }, - 404: { description: 'Tournament not found' } - } - } - }) - .put("/:tournamentId/complete", async ({ params, bearer }) => { - const { role } = this.jwt.decode(bearer as string) as { role: string }; - if (role !== UserProfileRole.ADMIN) { - throw new UnauthorizedError("You do not have permission to complete tournaments"); - } - - await this.tournamentRepository.completeTournament(params.tournamentId); - return { message: "Tournament completed" }; - }, { - detail: { - tags: ['Tournament Lifecycle'], - summary: 'Complete tournament', - description: 'Changes tournament status to completed. Requires admin privileges.', - security: [{ bearerAuth: [] }], - responses: { - 200: { - description: 'Tournament completed successfully', - content: { - 'application/json': { - example: { message: 'Tournament completed' } - } - } - }, - 401: { description: 'Unauthorized - Admin role required' }, - 404: { description: 'Tournament not found' } - } - } - }) - .put("/:tournamentId/cancel", async ({ params, bearer }) => { - const { role } = this.jwt.decode(bearer as string) as { role: string }; - if (role !== UserProfileRole.ADMIN) { - throw new UnauthorizedError("You do not have permission to cancel tournaments"); + throw new Error(`Failed to get entries: ${response.status} ${text}`); } - await this.tournamentRepository.cancelTournament(params.tournamentId); - return { message: "Tournament cancelled" }; + return response.json(); }, { detail: { - tags: ['Tournament Lifecycle'], - summary: 'Cancel tournament', - description: 'Changes tournament status to cancelled. Requires admin privileges.', - security: [{ bearerAuth: [] }], + tags: ['Lightning Tournaments'], + summary: 'Get tournament entries', + description: 'Retrieves all entries for a specific tournament.', responses: { 200: { - description: 'Tournament cancelled successfully', + description: 'Entries retrieved successfully', content: { 'application/json': { - example: { message: 'Tournament cancelled' } + example: { + entries: [ + { + id: '123', + userId: '456', + tournamentId: '789', + createdAt: '2023-01-01T00:00:00.000Z', + updatedAt: '2023-01-01T00:00:00.000Z' + } + ] + } } } }, - 401: { description: 'Unauthorized - Admin role required' }, 404: { description: 'Tournament not found' } } } }) - .put("/:tournamentId/entries/:participantId/confirm", async ({ params, bearer }) => { - const { role } = this.jwt.decode(bearer as string) as { role: string }; - if (role !== UserProfileRole.ADMIN) { - throw new UnauthorizedError("You do not have permission to confirm entries"); - } - - await this.tournamentRepository.confirmTournamentEntry(params.tournamentId, params.participantId); - return { message: "Entry confirmed" }; - }, { - detail: { - tags: ['Participant Management'], - summary: 'Confirm tournament entry', - description: 'Confirms a participant entry for a tournament. Requires admin privileges.', - security: [{ bearerAuth: [] }], - responses: { - 200: { - description: 'Entry confirmed successfully', - content: { - 'application/json': { - example: { message: 'Entry confirmed' } - } - } - }, - 401: { description: 'Unauthorized - Admin role required' }, - 404: { description: 'Tournament or participant not found' } - } - } - }) ); } } diff --git a/src/modules/lightning-tournaments/infrastructure/TournamentGateway.ts b/src/modules/tournaments/infrastructure/TournamentGateway.ts similarity index 100% rename from src/modules/lightning-tournaments/infrastructure/TournamentGateway.ts rename to src/modules/tournaments/infrastructure/TournamentGateway.ts diff --git a/src/modules/lightning-tournaments/infrastructure/LightningRankingPostgresRepository.ts b/src/modules/tournaments/infrastructure/TournamentRankingPostgresRepository.ts similarity index 96% rename from src/modules/lightning-tournaments/infrastructure/LightningRankingPostgresRepository.ts rename to src/modules/tournaments/infrastructure/TournamentRankingPostgresRepository.ts index 2785ef9..5ac4080 100644 --- a/src/modules/lightning-tournaments/infrastructure/LightningRankingPostgresRepository.ts +++ b/src/modules/tournaments/infrastructure/TournamentRankingPostgresRepository.ts @@ -4,7 +4,7 @@ import { TournamentRanking } from "../domain/TournamentRanking"; import { TournamentRankingRepository } from "../domain/TournamentRankingRepository"; import { RankingWithUser } from "../domain/RankingWithUser"; -export class LightningRankingPostgresRepository implements TournamentRankingRepository { +export class TournamentRankingPostgresRepository implements TournamentRankingRepository { async findByUserId(userId: string): Promise { const repository = dataSource.getRepository(LightningRankingEntity); const entity = await repository.findOne({ diff --git a/src/modules/lightning-tournaments/infrastructure/swagger-schemas.ts b/src/modules/tournaments/infrastructure/swagger-schemas.ts similarity index 100% rename from src/modules/lightning-tournaments/infrastructure/swagger-schemas.ts rename to src/modules/tournaments/infrastructure/swagger-schemas.ts diff --git a/src/modules/user/application/UserRegister.ts b/src/modules/user/application/UserRegister.ts index 698a462..afedcc3 100644 --- a/src/modules/user/application/UserRegister.ts +++ b/src/modules/user/application/UserRegister.ts @@ -23,7 +23,8 @@ export class UserRegister { throw new ConflictError(`User with email ${email} or username ${username} already exists`); } - const password = this.passwordGenerator(4); + // const password = this.passwordGenerator(4); + const password = "1234" this.logger.debug(`Password generate for email ${email} is ${password}`); const passwordHashed = await this.hash.hash(password); diff --git a/src/server/routes/lightning-tournament-router.ts b/src/server/routes/tournament-router.ts similarity index 52% rename from src/server/routes/lightning-tournament-router.ts rename to src/server/routes/tournament-router.ts index dd744ea..4dfdff7 100644 --- a/src/server/routes/lightning-tournament-router.ts +++ b/src/server/routes/tournament-router.ts @@ -1,19 +1,19 @@ import { Elysia } from "elysia"; -import { CreateTournamentProxyUseCase } from "../../modules/lightning-tournaments/application/CreateTournamentProxyUseCase"; -import { GetRankingUseCase } from "../../modules/lightning-tournaments/application/GetRankingUseCase"; -import { UpdateRankingUseCase } from "../../modules/lightning-tournaments/application/UpdateRankingUseCase"; -import { LightningTournamentController } from "../../modules/lightning-tournaments/infrastructure/LightningTournamentController"; -import { LightningRankingPostgresRepository } from "../../modules/lightning-tournaments/infrastructure/LightningRankingPostgresRepository"; +import { CreateTournamentProxyUseCase } from "../../modules/tournaments/application/CreateTournamentProxyUseCase"; +import { GetRankingUseCase } from "../../modules/tournaments/application/GetRankingUseCase"; +import { UpdateRankingUseCase } from "../../modules/tournaments/application/UpdateRankingUseCase"; +import { TournamentController } from "../../modules/tournaments/infrastructure/TournamentController"; +import { TournamentRankingPostgresRepository } from "../../modules/tournaments/infrastructure/TournamentRankingPostgresRepository"; import { UserPostgresRepository } from "../../modules/user/infrastructure/UserPostgresRepository"; import { config } from "src/config"; -import { TournamentEnrollmentUseCase } from "src/modules/lightning-tournaments/application/TournamentEnrollmentUseCase"; -import { TournamentWithdrawalUseCase } from "src/modules/lightning-tournaments/application/TournamentWithdrawalUseCase"; -import { TournamentGateway } from "src/modules/lightning-tournaments/infrastructure/TournamentGateway"; +import { TournamentEnrollmentUseCase } from "src/modules/tournaments/application/TournamentEnrollmentUseCase"; +import { TournamentWithdrawalUseCase } from "src/modules/tournaments/application/TournamentWithdrawalUseCase"; +import { TournamentGateway } from "src/modules/tournaments/infrastructure/TournamentGateway"; import { JWT } from "src/shared/JWT"; import { Pino } from "src/shared/logger/infrastructure/Pino"; const logger = new Pino(); -const repository = new LightningRankingPostgresRepository(); +const repository = new TournamentRankingPostgresRepository(); const userRepository = new UserPostgresRepository(); const tournamentRepository = new TournamentGateway(); const updateRanking = new UpdateRankingUseCase( @@ -33,7 +33,7 @@ const createTournament = new CreateTournamentProxyUseCase( config.tournaments.webhookUrl, ); -const controller = new LightningTournamentController( +const controller = new TournamentController( updateRanking, getRanking, createTournament, @@ -42,6 +42,6 @@ const controller = new LightningTournamentController( jwt ); -export const lightningTournamentRouter = new Elysia().use( +export const tournamentRouter = new Elysia().use( controller.routes(new Elysia()) ); diff --git a/src/server/routes/tournaments-proxy-router.ts b/src/server/routes/tournaments-proxy-router.ts deleted file mode 100644 index a852a3e..0000000 --- a/src/server/routes/tournaments-proxy-router.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Elysia } from "elysia"; -import { TournamentsProxyController } from "../../modules/lightning-tournaments/infrastructure/TournamentsProxyController"; -import { TournamentGateway } from "../../modules/lightning-tournaments/infrastructure/TournamentGateway"; -import { config } from "src/config"; -import { JWT } from "src/shared/JWT"; - -const jwt = new JWT(config.jwt); -const tournamentGateway = new TournamentGateway(); -const controller = new TournamentsProxyController(jwt, tournamentGateway); - -export const tournamentsProxyRouter = new Elysia().use( - controller.routes(new Elysia()) -); diff --git a/src/server/server.ts b/src/server/server.ts index 1a2d475..c30cff9 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -10,8 +10,7 @@ import { Logger } from "../shared/logger/domain/Logger"; import { banListRouter } from "./routes/ban-list-router"; import { leaderboardRouter } from "./routes/leaderboard-router"; -import { lightningTournamentRouter } from "./routes/lightning-tournament-router"; -import { tournamentsProxyRouter } from "./routes/tournaments-proxy-router"; +import { tournamentRouter } from "./routes/tournament-router"; import { userRouter } from "./routes/user-router"; export class Server { @@ -50,8 +49,8 @@ export class Server { description: 'Game ban list information' }, { - name: 'Lightning Tournaments', - description: 'Lightning tournament management and enrollment' + name: 'Tournaments', + description: 'Tournament management and enrollment' }, { name: 'Players & Participants', @@ -64,14 +63,6 @@ export class Server { { name: 'Match Management', description: 'Endpoints for managing match results and match data' - }, - { - name: 'Tournament Lifecycle', - description: 'Endpoints for managing tournament state transitions (publish, start, complete, cancel)' - }, - { - name: 'Participant Management', - description: 'Endpoints for managing tournament participants and entries' } ], components: { @@ -110,8 +101,7 @@ export class Server { .use(userRouter) .use(leaderboardRouter) .use(banListRouter) - .use(lightningTournamentRouter) - .use(tournamentsProxyRouter); + .use(tournamentRouter) }); this.logger = logger; } From fb6bc4e4d75fd1246c33a9317260dd26320e9697 Mon Sep 17 00:00:00 2001 From: Diango Gavidia Date: Mon, 24 Nov 2025 16:03:55 -0400 Subject: [PATCH 11/12] refactor: rollback password generation --- src/modules/user/application/UserRegister.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/modules/user/application/UserRegister.ts b/src/modules/user/application/UserRegister.ts index afedcc3..698a462 100644 --- a/src/modules/user/application/UserRegister.ts +++ b/src/modules/user/application/UserRegister.ts @@ -23,8 +23,7 @@ export class UserRegister { throw new ConflictError(`User with email ${email} or username ${username} already exists`); } - // const password = this.passwordGenerator(4); - const password = "1234" + const password = this.passwordGenerator(4); this.logger.debug(`Password generate for email ${email} is ${password}`); const passwordHashed = await this.hash.hash(password); From da1c90c344ea8e2b9fdd6a6ab5522ff4a52c07da Mon Sep 17 00:00:00 2001 From: Diango Gavidia Date: Mon, 24 Nov 2025 16:08:13 -0400 Subject: [PATCH 12/12] chore: update subproject commit reference in evolution-types --- src/evolution-types | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/evolution-types b/src/evolution-types index 25afaf8..69f8e1f 160000 --- a/src/evolution-types +++ b/src/evolution-types @@ -1 +1 @@ -Subproject commit 25afaf8f0c899341c6d2ede9fc41386863f862b7 +Subproject commit 69f8e1f572a684b00ee0d3b07e33efab1e987dde