Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ JWT_SECRET=
JWT_ISSUER=
RESEND_API_KEY=
RESEND_FROM_EMAIL=
SEASON=
SEASON=
TOURNAMENTS_API_URL=http://localhost:3000
TOURNAMENTS_WEBHOOK_URL=http://localhost:3001/api/v1/lightning-tournaments/webhook
5 changes: 4 additions & 1 deletion .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -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
SEASON=5
PORT=3001
TOURNAMENTS_API_URL=http://localhost:3000
TOURNAMENTS_WEBHOOK_URL=http://localhost:3001/api/v1/lightning-tournaments/webhook
5 changes: 2 additions & 3 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions init.sql
Original file line number Diff line number Diff line change
@@ -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';
4 changes: 4 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}
};
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

export class CreateTournamentProxyUseCase {
constructor(
private readonly tournamentsApiUrl: string,
private readonly webhookUrl: string
) { }

async execute(input: CreateTournamentInput): Promise<Tournament> {
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;
}
}
10 changes: 10 additions & 0 deletions src/modules/tournaments/application/GetRankingUseCase.ts
Original file line number Diff line number Diff line change
@@ -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<RankingWithUser[]> {
return this.repository.getTopRankings(limit);
}
}
24 changes: 24 additions & 0 deletions src/modules/tournaments/application/TournamentEnrollmentUseCase.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 });

}
}
21 changes: 21 additions & 0 deletions src/modules/tournaments/application/TournamentWithdrawalUseCase.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);

}
}
141 changes: 141 additions & 0 deletions src/modules/tournaments/application/UpdateRankingUseCase.ts
Original file line number Diff line number Diff line change
@@ -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<number, number> = {
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<void> {
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<Match[]> {
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;
}
}
10 changes: 10 additions & 0 deletions src/modules/tournaments/domain/RankingWithUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export interface RankingWithUser {
userId: string;
points: number;
tournamentsWon: number;
tournamentsPlayed: number;
user: {
username: string;
email: string;
} | null;
}
Loading