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/.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/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 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/evolution-types b/src/evolution-types index e29f969..69f8e1f 160000 --- a/src/evolution-types +++ b/src/evolution-types @@ -1 +1 @@ -Subproject commit e29f969d605900f489a961869b435003622d9aea +Subproject commit 69f8e1f572a684b00ee0d3b07e33efab1e987dde diff --git a/src/modules/tournaments/application/CreateTournamentProxyUseCase.ts b/src/modules/tournaments/application/CreateTournamentProxyUseCase.ts new file mode 100644 index 0000000..4f7bd61 --- /dev/null +++ b/src/modules/tournaments/application/CreateTournamentProxyUseCase.ts @@ -0,0 +1,62 @@ +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; + banlist?: string; // e.g., "Edison", "TCG", "OCG", "Goat" +} + +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 { banlist, ...tournamentFields } = input; + + const tournamentData = { + ...tournamentFields, + webhookUrl: this.webhookUrl, + status: "PUBLISHED", + metadata: banlist ? { banlist } : {} + }; + + 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/tournaments/application/GetRankingUseCase.ts b/src/modules/tournaments/application/GetRankingUseCase.ts new file mode 100644 index 0000000..3e62fdb --- /dev/null +++ b/src/modules/tournaments/application/GetRankingUseCase.ts @@ -0,0 +1,10 @@ +import { TournamentRankingRepository } from "../domain/TournamentRankingRepository"; +import { RankingWithUser } from "../domain/RankingWithUser"; + +export class GetRankingUseCase { + constructor(private readonly repository: TournamentRankingRepository) { } + + async execute(limit: number = 10): Promise { + return this.repository.getTopRankings(limit); + } +} diff --git a/src/modules/tournaments/application/TournamentEnrollmentUseCase.ts b/src/modules/tournaments/application/TournamentEnrollmentUseCase.ts new file mode 100644 index 0000000..54994dd --- /dev/null +++ b/src/modules/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/tournaments/application/TournamentWithdrawalUseCase.ts b/src/modules/tournaments/application/TournamentWithdrawalUseCase.ts new file mode 100644 index 0000000..478e978 --- /dev/null +++ b/src/modules/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/tournaments/application/UpdateRankingUseCase.ts b/src/modules/tournaments/application/UpdateRankingUseCase.ts new file mode 100644 index 0000000..dd08eea --- /dev/null +++ b/src/modules/tournaments/application/UpdateRankingUseCase.ts @@ -0,0 +1,141 @@ +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 = { + 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 tournamentsApiUrl: string, + private readonly logger: Logger + ) { } + + 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)}`); + + + // 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, + season: config.season.toString(), + }); + + } 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[] = []; + + // Process rounds from final to first + for (let round = maxRound; round >= 1; round--) { + const roundMatches = matches.filter(m => m.roundNumber === round && m.completedAt); + + for (const match of roundMatches) { + const winner = match.participants.find(p => p.result === "win"); + const loser = match.participants.find(p => p.result === "loss"); + + 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 }); + } + } + } + } + + return rankings; + } +} diff --git a/src/modules/tournaments/domain/RankingWithUser.ts b/src/modules/tournaments/domain/RankingWithUser.ts new file mode 100644 index 0000000..9e907c1 --- /dev/null +++ b/src/modules/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/tournaments/domain/TournamentRanking.ts b/src/modules/tournaments/domain/TournamentRanking.ts new file mode 100644 index 0000000..65555e7 --- /dev/null +++ b/src/modules/tournaments/domain/TournamentRanking.ts @@ -0,0 +1,108 @@ +export interface TournamentRankingProps { + userId: string; + points: number; + tournamentsWon: number; + tournamentsPlayed: number; + lastUpdated: Date; +} + +export class TournamentRanking { + private constructor( + private readonly userId: string, + private readonly _points: number, + private readonly _tournamentsWon: number, + private readonly _tournamentsPlayed: number, + private readonly _season: string + ) { } + + static createNew(props: { + userId: string; + points: number; + tournamentsWon: number; + tournamentsPlayed: number; + season: string; + }): TournamentRanking { + return new TournamentRanking( + props.userId, + props.points, + props.tournamentsWon, + props.tournamentsPlayed, + props.season, + ); + } + + static fromPrimitives(props: { + userId: string; + points: number; + tournamentsWon: number; + tournamentsPlayed: number; + season: string; + }): TournamentRanking { + return new TournamentRanking( + props.userId, + props.points, + props.tournamentsWon, + props.tournamentsPlayed, + props.season + ); + } + + addPoints(points: number): TournamentRanking { + return new TournamentRanking( + this.userId, + this._points + points, + this._tournamentsWon, + this._tournamentsPlayed, + this._season + ); + } + + incrementTournamentsPlayed(): TournamentRanking { + return new TournamentRanking( + this.userId, + this._points, + this._tournamentsWon, + this._tournamentsPlayed + 1, + this._season + ); + } + + incrementTournamentsWon(): TournamentRanking { + return new TournamentRanking( + this.userId, + this._points, + this._tournamentsWon + 1, + this._tournamentsPlayed, + this._season + ); + } + + get points(): number { + return this._points; + } + + get tournamentsWon(): number { + return this._tournamentsWon; + } + + get tournamentsPlayed(): number { + return this._tournamentsPlayed; + } + + get season(): string { + return this._season; + } + + getUserId(): string { + return this.userId; + } + + toPrimitives() { + return { + userId: this.userId, + points: this._points, + tournamentsWon: this._tournamentsWon, + tournamentsPlayed: this._tournamentsPlayed, + }; + } +} diff --git a/src/modules/tournaments/domain/TournamentRankingRepository.ts b/src/modules/tournaments/domain/TournamentRankingRepository.ts new file mode 100644 index 0000000..dbf49c9 --- /dev/null +++ b/src/modules/tournaments/domain/TournamentRankingRepository.ts @@ -0,0 +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; +} diff --git a/src/modules/tournaments/domain/TournamentRepository.ts b/src/modules/tournaments/domain/TournamentRepository.ts new file mode 100644 index 0000000..855d142 --- /dev/null +++ b/src/modules/tournaments/domain/TournamentRepository.ts @@ -0,0 +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/tournaments/infrastructure/TournamentController.ts b/src/modules/tournaments/infrastructure/TournamentController.ts new file mode 100644 index 0000000..6a61ad9 --- /dev/null +++ b/src/modules/tournaments/infrastructure/TournamentController.ts @@ -0,0 +1,454 @@ +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"; +import { config } from "src/config"; +import { MatchResultRequestSchema } from "./swagger-schemas"; + +export class TournamentController { + private readonly tournamentsApiUrl: string; + + 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 + ) { + this.tournamentsApiUrl = config.tournaments.apiUrl; + + } + + routes(app: Elysia) { + return app.group("/tournaments", (app) => + app + .use(bearer()) + .get("/", async () => { + const response = await fetch(`${this.tournamentsApiUrl}/tournaments`); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to get tournaments: ${response.status} ${text}`); + } + + return response.json(); + }, { + detail: { + tags: ['Lightning Tournaments'], + summary: 'Get all tournaments', + description: 'Retrieves a list of all tournaments from the tournaments service', + responses: { + 200: { + description: 'Tournaments retrieved successfully', + content: { + 'application/json': { + example: [ + { + id: 'tournament-001', + name: 'Tournament 1', + status: 'open' + }, + { + id: 'tournament-002', + name: 'Tournament 2', + status: 'closed' + } + ] + } + } + } + } + } + }) + .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: ['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', + 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' } + } + }, + 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("/: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(); + }, { + 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' } + } + } + }) + .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 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}`); + } + + 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' } + } + } + }) + .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' }, + 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(); + }, { + 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 + }) + .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" }; + }, { + 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' } + } + } + }) + .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 get entries: ${response.status} ${text}`); + } + + return response.json(); + }, { + detail: { + tags: ['Lightning Tournaments'], + summary: 'Get tournament entries', + description: 'Retrieves all entries for a specific tournament.', + responses: { + 200: { + description: 'Entries retrieved successfully', + content: { + 'application/json': { + example: { + entries: [ + { + id: '123', + userId: '456', + tournamentId: '789', + createdAt: '2023-01-01T00:00:00.000Z', + updatedAt: '2023-01-01T00:00:00.000Z' + } + ] + } + } + } + }, + 404: { description: 'Tournament not found' } + } + } + }) + ); + } +} diff --git a/src/modules/tournaments/infrastructure/TournamentGateway.ts b/src/modules/tournaments/infrastructure/TournamentGateway.ts new file mode 100644 index 0000000..5c0738f --- /dev/null +++ b/src/modules/tournaments/infrastructure/TournamentGateway.ts @@ -0,0 +1,139 @@ +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; + } + + 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/tournaments/infrastructure/TournamentRankingPostgresRepository.ts b/src/modules/tournaments/infrastructure/TournamentRankingPostgresRepository.ts new file mode 100644 index 0000000..5ac4080 --- /dev/null +++ b/src/modules/tournaments/infrastructure/TournamentRankingPostgresRepository.ts @@ -0,0 +1,72 @@ +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 TournamentRankingPostgresRepository implements TournamentRankingRepository { + async findByUserId(userId: string): Promise { + const repository = dataSource.getRepository(LightningRankingEntity); + const entity = await repository.findOne({ + where: { userId }, + relations: ["user"] + }); + + 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 existingEntity = await repository.findOne({ + where: { userId: ranking.getUserId() } + }); + + 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: ranking.season, + }); + await repository.save(newEntity); + } + } + + async getTopRankings(limit: number): Promise { + const repository = dataSource.getRepository(LightningRankingEntity); + const rankings = await repository.find({ + order: { points: "DESC" }, + take: limit, + relations: ["user"] + }); + + 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/tournaments/infrastructure/swagger-schemas.ts b/src/modules/tournaments/infrastructure/swagger-schemas.ts new file mode 100644 index 0000000..38ade7a --- /dev/null +++ b/src/modules/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/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/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/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/tournament-router.ts b/src/server/routes/tournament-router.ts new file mode 100644 index 0000000..4dfdff7 --- /dev/null +++ b/src/server/routes/tournament-router.ts @@ -0,0 +1,47 @@ +import { Elysia } from "elysia"; +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/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 TournamentRankingPostgresRepository(); +const userRepository = new UserPostgresRepository(); +const tournamentRepository = new TournamentGateway(); +const updateRanking = new UpdateRankingUseCase( + repository, + userRepository, + config.tournaments.apiUrl, + logger +); +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 +const createTournament = new CreateTournamentProxyUseCase( + config.tournaments.apiUrl, + config.tournaments.webhookUrl, +); + +const controller = new TournamentController( + updateRanking, + getRanking, + createTournament, + tournamentEnrollmentUseCase, + tournamentWithdrawalUseCase, + jwt +); + +export const tournamentRouter = new Elysia().use( + controller.routes(new Elysia()) +); 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 dd2a828..c30cff9 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -10,6 +10,7 @@ import { Logger } from "../shared/logger/domain/Logger"; import { banListRouter } from "./routes/ban-list-router"; import { leaderboardRouter } from "./routes/leaderboard-router"; +import { tournamentRouter } from "./routes/tournament-router"; import { userRouter } from "./routes/user-router"; export class Server { @@ -19,7 +20,63 @@ 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: 'Tournaments', + description: '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' + } + ], + 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; @@ -40,7 +97,11 @@ 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(tournamentRouter) }); this.logger = logger; } 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, }); }