diff --git a/adapters/bootstrap/LeagueScoringPresets.ts b/adapters/bootstrap/LeagueScoringPresets.ts index 99100030e..92fc34914 100644 --- a/adapters/bootstrap/LeagueScoringPresets.ts +++ b/adapters/bootstrap/LeagueScoringPresets.ts @@ -256,6 +256,177 @@ export const leagueScoringPresets: LeagueScoringPreset[] = [ }); }, }, + { + id: 'sprint-main-team', + name: 'Sprint + Main (Teams)', + description: 'Teams championship using the Sprint + Main weekend format.', + primaryChampionshipType: 'team', + dropPolicySummary: 'Best 6 results of 8 count towards the championship.', + sessionSummary: 'Sprint + Main', + bonusSummary: 'Fastest lap +1 point in main race if finishing P10 or better.', + defaultTimings: { + practiceMinutes: 20, + qualifyingMinutes: 30, + sprintRaceMinutes: 20, + mainRaceMinutes: 40, + sessionCount: 2, + }, + createConfig: ({ seasonId }) => { + const fastestLapBonus: BonusRule = { + id: 'fastest-lap-main-team', + type: 'fastestLap', + points: 1, + requiresFinishInTopN: 10, + }; + + const sessionTypes: SessionType[] = ['sprint', 'main']; + + const pointsTableBySessionType: Record = { + sprint: sprintPointsSprintMain, + main: mainPointsSprintMain, + practice: new PointsTable({}), + qualifying: new PointsTable({}), + q1: new PointsTable({}), + q2: new PointsTable({}), + q3: new PointsTable({}), + timeTrial: new PointsTable({}), + }; + + const bonusRulesBySessionType: Record = { + sprint: [], + main: [fastestLapBonus], + practice: [], + qualifying: [], + q1: [], + q2: [], + q3: [], + timeTrial: [], + }; + + const dropScorePolicy: DropScorePolicy = { + strategy: 'bestNResults', + count: 6, + }; + + const championship: ChampionshipConfig = { + id: 'team-champ-sprint-main', + name: 'Team Championship', + type: 'team' as ChampionshipType, + sessionTypes, + pointsTableBySessionType, + bonusRulesBySessionType, + dropScorePolicy, + }; + + return LeagueScoringConfig.create({ + id: `lsc-${seasonId}-sprint-main-team`, + seasonId, + scoringPresetId: 'sprint-main-team', + championships: [championship], + }); + }, + }, + { + id: 'club-default-nations', + name: 'Club ladder (Nations)', + description: 'Nation vs nation ladder with a single main race and no bonuses.', + primaryChampionshipType: 'nations', + dropPolicySummary: 'All race results count, no drop scores.', + sessionSummary: 'Main race only', + bonusSummary: 'No bonus points.', + defaultTimings: { + practiceMinutes: 20, + qualifyingMinutes: 20, + sprintRaceMinutes: 0, + mainRaceMinutes: 40, + sessionCount: 1, + }, + createConfig: ({ seasonId }) => { + const sessionTypes: SessionType[] = ['main']; + + const pointsTableBySessionType: Record = { + sprint: new PointsTable({}), + main: clubMainPoints, + practice: new PointsTable({}), + qualifying: new PointsTable({}), + q1: new PointsTable({}), + q2: new PointsTable({}), + q3: new PointsTable({}), + timeTrial: new PointsTable({}), + }; + + const dropScorePolicy: DropScorePolicy = { + strategy: 'none', + }; + + const championship: ChampionshipConfig = { + id: 'nations-champ-club-default', + name: 'Nations Championship', + type: 'nations' as ChampionshipType, + sessionTypes, + pointsTableBySessionType, + dropScorePolicy, + }; + + return LeagueScoringConfig.create({ + id: `lsc-${seasonId}-club-default-nations`, + seasonId, + scoringPresetId: 'club-default-nations', + championships: [championship], + }); + }, + }, + { + id: 'endurance-main-trophy', + name: 'Endurance Trophy Event', + description: 'Trophy-style endurance event with a single long main race.', + primaryChampionshipType: 'trophy', + dropPolicySummary: 'Best 4 results of 6 count towards the championship.', + sessionSummary: 'Main race only', + bonusSummary: 'No bonus points.', + defaultTimings: { + practiceMinutes: 30, + qualifyingMinutes: 20, + sprintRaceMinutes: 0, + mainRaceMinutes: 120, + sessionCount: 1, + }, + createConfig: ({ seasonId }) => { + const sessionTypes: SessionType[] = ['main']; + + const pointsTableBySessionType: Record = { + sprint: new PointsTable({}), + main: enduranceMainPoints, + practice: new PointsTable({}), + qualifying: new PointsTable({}), + q1: new PointsTable({}), + q2: new PointsTable({}), + q3: new PointsTable({}), + timeTrial: new PointsTable({}), + }; + + const dropScorePolicy: DropScorePolicy = { + strategy: 'bestNResults', + count: 4, + }; + + const championship: ChampionshipConfig = { + id: 'trophy-champ-endurance-main', + name: 'Trophy Championship', + type: 'trophy' as ChampionshipType, + sessionTypes, + pointsTableBySessionType, + dropScorePolicy, + }; + + return LeagueScoringConfig.create({ + id: `lsc-${seasonId}-endurance-main-trophy`, + seasonId, + scoringPresetId: 'endurance-main-trophy', + championships: [championship], + }); + }, + }, ]; export function listLeagueScoringPresets(): LeagueScoringPreset[] { diff --git a/adapters/bootstrap/SeedRacingData.ts b/adapters/bootstrap/SeedRacingData.ts index 7e54fa145..1dfca1f2e 100644 --- a/adapters/bootstrap/SeedRacingData.ts +++ b/adapters/bootstrap/SeedRacingData.ts @@ -10,10 +10,13 @@ import type { ITeamRepository } from '@core/racing/domain/repositories/ITeamRepo import type { ITeamMembershipRepository } from '@core/racing/domain/repositories/ITeamMembershipRepository'; import type { ISponsorRepository } from '@core/racing/domain/repositories/ISponsorRepository'; import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository'; +import type { ILeagueScoringConfigRepository } from '@core/racing/domain/repositories/ILeagueScoringConfigRepository'; import type { ISeasonSponsorshipRepository } from '@core/racing/domain/repositories/ISeasonSponsorshipRepository'; import type { ISponsorshipRequestRepository } from '@core/racing/domain/repositories/ISponsorshipRequestRepository'; import type { ILeagueWalletRepository } from '@core/racing/domain/repositories/ILeagueWalletRepository'; import type { ITransactionRepository } from '@core/racing/domain/repositories/ITransactionRepository'; +import type { Season } from '@core/racing/domain/entities/season/Season'; +import { getLeagueScoringPresetById } from './LeagueScoringPresets'; import type { IProtestRepository } from '@core/racing/domain/repositories/IProtestRepository'; import type { IPenaltyRepository } from '@core/racing/domain/repositories/IPenaltyRepository'; import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository'; @@ -24,6 +27,7 @@ export type RacingSeedDependencies = { driverRepository: IDriverRepository; leagueRepository: ILeagueRepository; seasonRepository: ISeasonRepository; + leagueScoringConfigRepository: ILeagueScoringConfigRepository; seasonSponsorshipRepository: ISeasonSponsorshipRepository; sponsorshipRequestRepository: ISponsorshipRequestRepository; leagueWalletRepository: ILeagueWalletRepository; @@ -51,7 +55,8 @@ export class SeedRacingData { async execute(): Promise { const existingDrivers = await this.seedDeps.driverRepository.findAll(); if (existingDrivers.length > 0) { - this.logger.info('[Bootstrap] Racing seed skipped (drivers already exist)'); + this.logger.info('[Bootstrap] Racing seed skipped (drivers already exist), ensuring scoring configs'); + await this.ensureScoringConfigsForExistingData(); return; } @@ -90,6 +95,26 @@ export class SeedRacingData { } } + const activeSeasons = seed.seasons.filter((season) => season.status === 'active'); + for (const season of activeSeasons) { + const presetId = this.selectScoringPresetIdForSeason(season); + const preset = getLeagueScoringPresetById(presetId); + + if (!preset) { + this.logger.warn( + `[Bootstrap] Scoring preset not found (presetId=${presetId}, seasonId=${season.id}, leagueId=${season.leagueId})`, + ); + continue; + } + + const scoringConfig = preset.createConfig({ seasonId: season.id }); + + try { + await this.seedDeps.leagueScoringConfigRepository.save(scoringConfig); + } catch { + // ignore duplicates + } + } for (const sponsorship of seed.seasonSponsorships) { try { @@ -239,4 +264,64 @@ export class SeedRacingData { `[Bootstrap] Seeded racing data: drivers=${seed.drivers.length}, leagues=${seed.leagues.length}, races=${seed.races.length}`, ); } + + private async ensureScoringConfigsForExistingData(): Promise { + const leagues = await this.seedDeps.leagueRepository.findAll(); + + for (const league of leagues) { + const seasons = await this.seedDeps.seasonRepository.findByLeagueId(league.id.toString()); + const activeSeasons = seasons.filter((season) => season.status === 'active'); + + for (const season of activeSeasons) { + const existing = await this.seedDeps.leagueScoringConfigRepository.findBySeasonId(season.id); + if (existing) continue; + + const presetId = this.selectScoringPresetIdForSeason(season); + const preset = getLeagueScoringPresetById(presetId); + + if (!preset) { + this.logger.warn( + `[Bootstrap] Scoring preset not found (presetId=${presetId}, seasonId=${season.id}, leagueId=${season.leagueId})`, + ); + continue; + } + + const scoringConfig = preset.createConfig({ seasonId: season.id }); + + try { + await this.seedDeps.leagueScoringConfigRepository.save(scoringConfig); + } catch { + // ignore duplicates + } + } + } + } + + private selectScoringPresetIdForSeason(season: Season): string { + if (season.leagueId === 'league-5' && season.status === 'active') { + return 'sprint-main-driver'; + } + + if (season.leagueId === 'league-3') { + return season.id.endsWith('-b') ? 'sprint-main-team' : 'club-default-nations'; + } + + const match = /^league-(\d+)$/.exec(season.leagueId); + const leagueNumber = match ? Number(match[1]) : undefined; + + if (leagueNumber !== undefined) { + switch (leagueNumber % 4) { + case 0: + return 'sprint-main-team'; + case 1: + return 'endurance-main-trophy'; + case 2: + return 'sprint-main-driver'; + case 3: + return 'club-default-nations'; + } + } + + return 'club-default'; + } } \ No newline at end of file diff --git a/adapters/bootstrap/racing/RacingLeagueFactory.ts b/adapters/bootstrap/racing/RacingLeagueFactory.ts index 2a8703842..870430bbc 100644 --- a/adapters/bootstrap/racing/RacingLeagueFactory.ts +++ b/adapters/bootstrap/racing/RacingLeagueFactory.ts @@ -33,6 +33,12 @@ export class RacingLeagueFactory { const owner = faker.helpers.arrayElement(this.drivers); const config = leagueConfigs[idx % leagueConfigs.length]!; + const createdAt = + // Ensure some "New" leagues (created within 7 days) so `/leagues` featured categories are populated. + idx % 6 === 0 + ? faker.date.recent({ days: 6, refDate: this.baseDate }) + : faker.date.past({ years: 2, refDate: this.baseDate }); + const leagueData: { id: string; name: string; @@ -52,7 +58,7 @@ export class RacingLeagueFactory { description: faker.lorem.sentences(2), ownerId: owner.id.toString(), settings: config, - createdAt: faker.date.past({ years: 2, refDate: this.baseDate }), + createdAt, }; // Add social links with varying completeness diff --git a/adapters/bootstrap/racing/RacingSeed.ts b/adapters/bootstrap/racing/RacingSeed.ts index b06e18046..83c396089 100644 --- a/adapters/bootstrap/racing/RacingSeed.ts +++ b/adapters/bootstrap/racing/RacingSeed.ts @@ -68,7 +68,7 @@ export const racingSeedDefaults: Readonly< Required > = { driverCount: 100, - baseDate: new Date('2025-01-15T12:00:00.000Z'), + baseDate: new Date(), }; class RacingSeedFactory { diff --git a/adapters/racing/persistence/inmemory/InMemoryGameRepository.test.ts b/adapters/racing/persistence/inmemory/InMemoryGameRepository.test.ts index 0655b0581..ba514e0f2 100644 --- a/adapters/racing/persistence/inmemory/InMemoryGameRepository.test.ts +++ b/adapters/racing/persistence/inmemory/InMemoryGameRepository.test.ts @@ -33,9 +33,13 @@ describe('InMemoryGameRepository', () => { }); describe('findAll', () => { - it('should return an empty array', async () => { + it('should return default seeded games', async () => { const result = await repository.findAll(); - expect(result).toEqual([]); + + const ids = result.map((g) => g.id.toString()); + expect(ids).toEqual(expect.arrayContaining(['iracing', 'acc', 'f1-24', 'f1-23'])); + expect(result).toHaveLength(4); + expect(mockLogger.debug).toHaveBeenCalledWith('[InMemoryGameRepository] Finding all games.'); }); }); diff --git a/adapters/racing/persistence/inmemory/InMemoryGameRepository.ts b/adapters/racing/persistence/inmemory/InMemoryGameRepository.ts index b5a012c37..d3a1c86f3 100644 --- a/adapters/racing/persistence/inmemory/InMemoryGameRepository.ts +++ b/adapters/racing/persistence/inmemory/InMemoryGameRepository.ts @@ -7,6 +7,20 @@ export class InMemoryGameRepository implements IGameRepository { constructor(private readonly logger: Logger) { this.logger.info('InMemoryGameRepository initialized.'); + this.seedDefaults(); + } + + private seedDefaults(): void { + const defaults = [ + Game.create({ id: 'iracing', name: 'iRacing' }), + Game.create({ id: 'acc', name: 'Assetto Corsa Competizione' }), + Game.create({ id: 'f1-24', name: 'F1 24' }), + Game.create({ id: 'f1-23', name: 'F1 23' }), + ]; + + for (const game of defaults) { + this.games.set(game.id.toString(), game); + } } async findById(id: string): Promise { diff --git a/adapters/racing/persistence/inmemory/InMemoryLeagueScoringPresetProvider.test.ts b/adapters/racing/persistence/inmemory/InMemoryLeagueScoringPresetProvider.test.ts index 953c140c2..572672fb9 100644 --- a/adapters/racing/persistence/inmemory/InMemoryLeagueScoringPresetProvider.test.ts +++ b/adapters/racing/persistence/inmemory/InMemoryLeagueScoringPresetProvider.test.ts @@ -8,7 +8,7 @@ describe('InMemoryLeagueScoringPresetProvider', () => { it('should return all available presets', () => { const presets = provider.listPresets(); - expect(presets).toHaveLength(3); + expect(presets.length).toBeGreaterThanOrEqual(3); expect(presets).toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/apps/api/src/domain/bootstrap/BootstrapProviders.ts b/apps/api/src/domain/bootstrap/BootstrapProviders.ts index cc022d9df..c30a41359 100644 --- a/apps/api/src/domain/bootstrap/BootstrapProviders.ts +++ b/apps/api/src/domain/bootstrap/BootstrapProviders.ts @@ -47,6 +47,7 @@ export const BootstrapProviders: Provider[] = [ driverRepository: RacingSeedDependencies['driverRepository'], leagueRepository: RacingSeedDependencies['leagueRepository'], seasonRepository: RacingSeedDependencies['seasonRepository'], + leagueScoringConfigRepository: RacingSeedDependencies['leagueScoringConfigRepository'], seasonSponsorshipRepository: RacingSeedDependencies['seasonSponsorshipRepository'], sponsorshipRequestRepository: RacingSeedDependencies['sponsorshipRequestRepository'], leagueWalletRepository: RacingSeedDependencies['leagueWalletRepository'], @@ -67,6 +68,7 @@ export const BootstrapProviders: Provider[] = [ driverRepository, leagueRepository, seasonRepository, + leagueScoringConfigRepository, seasonSponsorshipRepository, sponsorshipRequestRepository, leagueWalletRepository, @@ -88,6 +90,7 @@ export const BootstrapProviders: Provider[] = [ 'IDriverRepository', 'ILeagueRepository', 'ISeasonRepository', + 'ILeagueScoringConfigRepository', 'ISeasonSponsorshipRepository', 'ISponsorshipRequestRepository', 'ILeagueWalletRepository', diff --git a/apps/api/src/domain/league/LeagueController.ts b/apps/api/src/domain/league/LeagueController.ts index 861d803df..47b6cffb4 100644 --- a/apps/api/src/domain/league/LeagueController.ts +++ b/apps/api/src/domain/league/LeagueController.ts @@ -3,6 +3,7 @@ import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { Public } from '../auth/Public'; import { LeagueService } from './LeagueService'; import { AllLeaguesWithCapacityDTO } from './dtos/AllLeaguesWithCapacityDTO'; +import { AllLeaguesWithCapacityAndScoringDTO } from './dtos/AllLeaguesWithCapacityAndScoringDTO'; import { ApproveJoinRequestInputDTO } from './dtos/ApproveJoinRequestInputDTO'; import { ApproveJoinRequestOutputDTO } from './dtos/ApproveJoinRequestOutputDTO'; import { CreateLeagueInputDTO } from './dtos/CreateLeagueInputDTO'; @@ -49,6 +50,14 @@ export class LeagueController { return this.leagueService.getAllLeaguesWithCapacity(); } + @Public() + @Get('all-with-capacity-and-scoring') + @ApiOperation({ summary: 'Get all leagues with capacity and scoring information' }) + @ApiResponse({ status: 200, description: 'List of leagues with capacity and scoring', type: AllLeaguesWithCapacityAndScoringDTO }) + async getAllLeaguesWithCapacityAndScoring(): Promise { + return this.leagueService.getAllLeaguesWithCapacityAndScoring(); + } + @Public() @Get('total-leagues') @ApiOperation({ summary: 'Get the total number of leagues' }) diff --git a/apps/api/src/domain/league/LeagueProviders.ts b/apps/api/src/domain/league/LeagueProviders.ts index 0e216cd4d..ff97f8d08 100644 --- a/apps/api/src/domain/league/LeagueProviders.ts +++ b/apps/api/src/domain/league/LeagueProviders.ts @@ -15,16 +15,15 @@ import type { Logger } from '@core/shared/application/Logger'; // Import concrete in-memory implementations import type { ILeagueWalletRepository } from "@core/racing/domain/repositories/ILeagueWalletRepository"; import type { ITransactionRepository } from "@core/racing/domain/repositories/ITransactionRepository"; -import { listLeagueScoringPresets } from '@adapters/bootstrap/LeagueScoringPresets'; +import { getLeagueScoringPresetById, listLeagueScoringPresets } from '@adapters/bootstrap/LeagueScoringPresets'; import { ConsoleLogger } from '@adapters/logging/ConsoleLogger'; -import { InMemoryGameRepository } from '@adapters/racing/persistence/inmemory/InMemoryGameRepository'; -import { InMemoryLeagueScoringConfigRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueScoringConfigRepository'; import { InMemoryLeagueStandingsRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueStandingsRepository'; // Import use cases import { ApproveLeagueJoinRequestUseCase } from '@core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase'; import { CreateLeagueWithSeasonAndScoringUseCase } from '@core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase'; import { GetAllLeaguesWithCapacityUseCase } from '@core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase'; +import { GetAllLeaguesWithCapacityAndScoringUseCase } from '@core/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringUseCase'; import { GetLeagueAdminPermissionsUseCase } from '@core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase'; import { GetLeagueFullConfigUseCase } from '@core/racing/application/use-cases/GetLeagueFullConfigUseCase'; import { GetLeagueJoinRequestsUseCase } from '@core/racing/application/use-cases/GetLeagueJoinRequestsUseCase'; @@ -50,6 +49,7 @@ import { WithdrawFromLeagueWalletUseCase } from '@core/racing/application/use-ca // Import presenters import { AllLeaguesWithCapacityPresenter } from './presenters/AllLeaguesWithCapacityPresenter'; +import { AllLeaguesWithCapacityAndScoringPresenter } from './presenters/AllLeaguesWithCapacityAndScoringPresenter'; import { ApproveLeagueJoinRequestPresenter } from './presenters/ApproveLeagueJoinRequestPresenter'; import { CreateLeaguePresenter } from './presenters/CreateLeaguePresenter'; import { GetLeagueAdminPermissionsPresenter } from './presenters/GetLeagueAdminPermissionsPresenter'; @@ -89,6 +89,7 @@ export const TRANSACTION_REPOSITORY_TOKEN = 'ITransactionRepository'; export const LOGGER_TOKEN = 'Logger'; export const GET_LEAGUE_STANDINGS_USE_CASE = 'GetLeagueStandingsUseCase'; export const GET_ALL_LEAGUES_WITH_CAPACITY_USE_CASE = 'GetAllLeaguesWithCapacityUseCase'; +export const GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_USE_CASE = 'GetAllLeaguesWithCapacityAndScoringUseCase'; export const GET_LEAGUE_STATS_USE_CASE = 'GetLeagueStatsUseCase'; export const GET_LEAGUE_FULL_CONFIG_USE_CASE = 'GetLeagueFullConfigUseCase'; export const GET_LEAGUE_SCORING_CONFIG_USE_CASE = 'GetLeagueScoringConfigUseCase'; @@ -114,6 +115,7 @@ export const WITHDRAW_FROM_LEAGUE_WALLET_USE_CASE = 'WithdrawFromLeagueWalletUse export const GET_SEASON_SPONSORSHIPS_USE_CASE = 'GetSeasonSponsorshipsUseCase'; export const GET_ALL_LEAGUES_WITH_CAPACITY_OUTPUT_PORT_TOKEN = 'GetAllLeaguesWithCapacityOutputPort_TOKEN'; +export const GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_OUTPUT_PORT_TOKEN = 'GetAllLeaguesWithCapacityAndScoringOutputPort_TOKEN'; export const GET_LEAGUE_STANDINGS_OUTPUT_PORT_TOKEN = 'GetLeagueStandingsOutputPort_TOKEN'; export const GET_LEAGUE_PROTESTS_OUTPUT_PORT_TOKEN = 'GetLeagueProtestsOutputPort_TOKEN'; export const GET_SEASON_SPONSORSHIPS_OUTPUT_PORT_TOKEN = 'GetSeasonSponsorshipsOutputPort_TOKEN'; @@ -144,22 +146,13 @@ export const LeagueProviders: Provider[] = [ useFactory: (logger: Logger) => new InMemoryLeagueStandingsRepository(logger), // Factory for InMemoryLeagueStandingsRepository inject: [LOGGER_TOKEN], }, - { - provide: LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN, - useFactory: (logger: Logger) => new InMemoryLeagueScoringConfigRepository(logger), // Factory for InMemoryLeagueScoringConfigRepository - inject: [LOGGER_TOKEN], - }, - { - provide: GAME_REPOSITORY_TOKEN, - useFactory: (logger: Logger) => new InMemoryGameRepository(logger), - inject: [LOGGER_TOKEN], - }, { provide: LOGGER_TOKEN, useClass: ConsoleLogger, }, // Presenters AllLeaguesWithCapacityPresenter, + AllLeaguesWithCapacityAndScoringPresenter, ApproveLeagueJoinRequestPresenter, CreateLeaguePresenter, GetLeagueAdminPermissionsPresenter, @@ -189,6 +182,10 @@ export const LeagueProviders: Provider[] = [ provide: GET_ALL_LEAGUES_WITH_CAPACITY_OUTPUT_PORT_TOKEN, useExisting: AllLeaguesWithCapacityPresenter, }, + { + provide: GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_OUTPUT_PORT_TOKEN, + useExisting: AllLeaguesWithCapacityAndScoringPresenter, + }, { provide: GET_LEAGUE_STANDINGS_OUTPUT_PORT_TOKEN, useExisting: LeagueStandingsPresenter, @@ -284,6 +281,34 @@ export const LeagueProviders: Provider[] = [ new GetAllLeaguesWithCapacityUseCase(leagueRepo, membershipRepo), inject: [LEAGUE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN], }, + { + provide: GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_USE_CASE, + useFactory: ( + leagueRepo: ILeagueRepository, + membershipRepo: ILeagueMembershipRepository, + seasonRepo: ISeasonRepository, + scoringRepo: import('@core/racing/domain/repositories/ILeagueScoringConfigRepository').ILeagueScoringConfigRepository, + gameRepo: import('@core/racing/domain/repositories/IGameRepository').IGameRepository, + output: AllLeaguesWithCapacityAndScoringPresenter, + ) => + new GetAllLeaguesWithCapacityAndScoringUseCase( + leagueRepo, + membershipRepo, + seasonRepo, + scoringRepo, + gameRepo, + { getPresetById: getLeagueScoringPresetById }, + output, + ), + inject: [ + LEAGUE_REPOSITORY_TOKEN, + LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, + SEASON_REPOSITORY_TOKEN, + LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN, + GAME_REPOSITORY_TOKEN, + GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_OUTPUT_PORT_TOKEN, + ], + }, { provide: GET_LEAGUE_STANDINGS_USE_CASE, useFactory: ( diff --git a/apps/api/src/domain/league/LeagueService.test.ts b/apps/api/src/domain/league/LeagueService.test.ts index 8e4844547..1cf849751 100644 --- a/apps/api/src/domain/league/LeagueService.test.ts +++ b/apps/api/src/domain/league/LeagueService.test.ts @@ -10,6 +10,7 @@ describe('LeagueService', () => { const err = async () => Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } }); const getAllLeaguesWithCapacityUseCase: any = { execute: vi.fn(async () => Result.ok({ leagues: [] })) }; + const getAllLeaguesWithCapacityAndScoringUseCase: any = { execute: vi.fn(ok) }; const getLeagueStandingsUseCase = { execute: vi.fn(ok) }; const getLeagueStatsUseCase = { execute: vi.fn(ok) }; const getLeagueFullConfigUseCase: any = { execute: vi.fn(ok) }; @@ -35,6 +36,10 @@ describe('LeagueService', () => { const getSeasonSponsorshipsUseCase = { execute: vi.fn(ok) }; const allLeaguesWithCapacityPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ leagues: [] })) }; + const allLeaguesWithCapacityAndScoringPresenter = { + present: vi.fn(), + getViewModel: vi.fn(() => ({ leagues: [], totalCount: 0 })), + }; const leagueStandingsPresenter = { getResponseModel: vi.fn(() => ({ standings: [] })) }; const leagueProtestsPresenter = { getResponseModel: vi.fn(() => ({ protests: [] })) }; const seasonSponsorshipsPresenter = { getViewModel: vi.fn(() => ({ sponsorships: [] })) }; @@ -62,6 +67,7 @@ describe('LeagueService', () => { const service = new LeagueService( getAllLeaguesWithCapacityUseCase as any, + getAllLeaguesWithCapacityAndScoringUseCase as any, getLeagueStandingsUseCase as any, getLeagueStatsUseCase as any, getLeagueFullConfigUseCase as any, @@ -87,6 +93,7 @@ describe('LeagueService', () => { getSeasonSponsorshipsUseCase as any, logger as any, allLeaguesWithCapacityPresenter as any, + allLeaguesWithCapacityAndScoringPresenter as any, leagueStandingsPresenter as any, leagueProtestsPresenter as any, seasonSponsorshipsPresenter as any, @@ -149,6 +156,7 @@ describe('LeagueService', () => { }); await expect(service.getAllLeaguesWithCapacity()).resolves.toEqual({ leagues: [] }); + await expect(service.getAllLeaguesWithCapacityAndScoring()).resolves.toEqual({ leagues: [], totalCount: 0 }); // Error branch: getAllLeaguesWithCapacity throws on result.isErr() getAllLeaguesWithCapacityUseCase.execute.mockResolvedValueOnce(Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } })); diff --git a/apps/api/src/domain/league/LeagueService.ts b/apps/api/src/domain/league/LeagueService.ts index cbbda3e8e..c7a817ddd 100644 --- a/apps/api/src/domain/league/LeagueService.ts +++ b/apps/api/src/domain/league/LeagueService.ts @@ -33,6 +33,7 @@ import { WithdrawFromLeagueWalletOutputDTO } from './dtos/WithdrawFromLeagueWall // Core imports for view models import type { AllLeaguesWithCapacityDTO as AllLeaguesWithCapacityViewModel } from './dtos/AllLeaguesWithCapacityDTO'; +import type { AllLeaguesWithCapacityAndScoringDTO as AllLeaguesWithCapacityAndScoringViewModel } from './dtos/AllLeaguesWithCapacityAndScoringDTO'; import type { CreateLeagueViewModel } from './dtos/CreateLeagueDTO'; import type { JoinLeagueOutputDTO } from './dtos/JoinLeagueOutputDTO'; import { TotalLeaguesDTO } from './dtos/TotalLeaguesDTO'; @@ -46,6 +47,7 @@ import type { Logger } from '@core/shared/application'; import { ApproveLeagueJoinRequestUseCase } from '@core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase'; import { CreateLeagueWithSeasonAndScoringUseCase } from '@core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase'; import { GetAllLeaguesWithCapacityUseCase } from '@core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase'; +import { GetAllLeaguesWithCapacityAndScoringUseCase } from '@core/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringUseCase'; import { GetLeagueAdminPermissionsUseCase } from '@core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase'; import { GetLeagueFullConfigUseCase } from '@core/racing/application/use-cases/GetLeagueFullConfigUseCase'; import { GetLeagueJoinRequestsUseCase } from '@core/racing/application/use-cases/GetLeagueJoinRequestsUseCase'; @@ -70,6 +72,7 @@ import { WithdrawFromLeagueWalletUseCase } from '@core/racing/application/use-ca // API Presenters import { AllLeaguesWithCapacityPresenter } from './presenters/AllLeaguesWithCapacityPresenter'; +import { AllLeaguesWithCapacityAndScoringPresenter } from './presenters/AllLeaguesWithCapacityAndScoringPresenter'; import { ApproveLeagueJoinRequestPresenter } from './presenters/ApproveLeagueJoinRequestPresenter'; import { CreateLeaguePresenter } from './presenters/CreateLeaguePresenter'; import { GetLeagueAdminPermissionsPresenter } from './presenters/GetLeagueAdminPermissionsPresenter'; @@ -98,6 +101,7 @@ import { LOGGER_TOKEN, GET_ALL_LEAGUES_WITH_CAPACITY_USE_CASE, GET_LEAGUE_STANDINGS_USE_CASE, + GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_USE_CASE, GET_LEAGUE_STATS_USE_CASE, GET_LEAGUE_FULL_CONFIG_USE_CASE, GET_LEAGUE_SCORING_CONFIG_USE_CASE, @@ -121,6 +125,7 @@ import { WITHDRAW_FROM_LEAGUE_WALLET_USE_CASE, GET_SEASON_SPONSORSHIPS_USE_CASE, GET_ALL_LEAGUES_WITH_CAPACITY_OUTPUT_PORT_TOKEN, + GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_OUTPUT_PORT_TOKEN, GET_LEAGUE_STANDINGS_OUTPUT_PORT_TOKEN, GET_LEAGUE_PROTESTS_OUTPUT_PORT_TOKEN, GET_SEASON_SPONSORSHIPS_OUTPUT_PORT_TOKEN, @@ -149,6 +154,7 @@ import { export class LeagueService { constructor( @Inject(GET_ALL_LEAGUES_WITH_CAPACITY_USE_CASE) private readonly getAllLeaguesWithCapacityUseCase: GetAllLeaguesWithCapacityUseCase, + @Inject(GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_USE_CASE) private readonly getAllLeaguesWithCapacityAndScoringUseCase: GetAllLeaguesWithCapacityAndScoringUseCase, @Inject(GET_LEAGUE_STANDINGS_USE_CASE) private readonly getLeagueStandingsUseCase: GetLeagueStandingsUseCase, @Inject(GET_LEAGUE_STATS_USE_CASE) private readonly getLeagueStatsUseCase: GetLeagueStatsUseCase, @Inject(GET_LEAGUE_FULL_CONFIG_USE_CASE) private readonly getLeagueFullConfigUseCase: GetLeagueFullConfigUseCase, @@ -175,6 +181,7 @@ export class LeagueService { @Inject(LOGGER_TOKEN) private readonly logger: Logger, // Injected presenters @Inject(GET_ALL_LEAGUES_WITH_CAPACITY_OUTPUT_PORT_TOKEN) private readonly allLeaguesWithCapacityPresenter: AllLeaguesWithCapacityPresenter, + @Inject(GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_OUTPUT_PORT_TOKEN) private readonly allLeaguesWithCapacityAndScoringPresenter: AllLeaguesWithCapacityAndScoringPresenter, @Inject(GET_LEAGUE_STANDINGS_OUTPUT_PORT_TOKEN) private readonly leagueStandingsPresenter: LeagueStandingsPresenter, @Inject(GET_LEAGUE_PROTESTS_OUTPUT_PORT_TOKEN) private readonly leagueProtestsPresenter: GetLeagueProtestsPresenter, @Inject(GET_SEASON_SPONSORSHIPS_OUTPUT_PORT_TOKEN) private readonly seasonSponsorshipsPresenter: GetSeasonSponsorshipsPresenter, @@ -218,6 +225,22 @@ export class LeagueService { return this.allLeaguesWithCapacityPresenter.getViewModel(); } + async getAllLeaguesWithCapacityAndScoring(): Promise { + this.logger.debug('[LeagueService] Fetching all leagues with capacity and scoring.'); + + const result = await this.getAllLeaguesWithCapacityAndScoringUseCase.execute({}); + + if (result.isErr()) { + const err = result.unwrapErr(); + this.logger.error('[LeagueService] Failed to fetch leagues with capacity and scoring', new Error(err.code), { + details: err.details, + }); + throw new Error(err.code); + } + + return this.allLeaguesWithCapacityAndScoringPresenter.getViewModel(); + } + async getTotalLeagues(): Promise { this.logger.debug('[LeagueService] Fetching total leagues count.'); await this.getTotalLeaguesUseCase.execute({}); diff --git a/apps/api/src/domain/league/LeagueTokens.ts b/apps/api/src/domain/league/LeagueTokens.ts index f4e44d566..164b63e8b 100644 --- a/apps/api/src/domain/league/LeagueTokens.ts +++ b/apps/api/src/domain/league/LeagueTokens.ts @@ -14,6 +14,7 @@ export const LOGGER_TOKEN = 'Logger'; export const GET_LEAGUE_STANDINGS_USE_CASE = 'GetLeagueStandingsUseCase'; export const GET_ALL_LEAGUES_WITH_CAPACITY_USE_CASE = 'GetAllLeaguesWithCapacityUseCase'; +export const GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_USE_CASE = 'GetAllLeaguesWithCapacityAndScoringUseCase'; export const GET_LEAGUE_STATS_USE_CASE = 'GetLeagueStatsUseCase'; export const GET_LEAGUE_FULL_CONFIG_USE_CASE = 'GetLeagueFullConfigUseCase'; export const GET_LEAGUE_SCORING_CONFIG_USE_CASE = 'GetLeagueScoringConfigUseCase'; @@ -39,6 +40,7 @@ export const WITHDRAW_FROM_LEAGUE_WALLET_USE_CASE = 'WithdrawFromLeagueWalletUse export const GET_SEASON_SPONSORSHIPS_USE_CASE = 'GetSeasonSponsorshipsUseCase'; export const GET_ALL_LEAGUES_WITH_CAPACITY_OUTPUT_PORT_TOKEN = 'GetAllLeaguesWithCapacityOutputPort_TOKEN'; +export const GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_OUTPUT_PORT_TOKEN = 'GetAllLeaguesWithCapacityAndScoringOutputPort_TOKEN'; export const GET_LEAGUE_STANDINGS_OUTPUT_PORT_TOKEN = 'GetLeagueStandingsOutputPort_TOKEN'; export const GET_LEAGUE_PROTESTS_OUTPUT_PORT_TOKEN = 'GetLeagueProtestsOutputPort_TOKEN'; export const GET_SEASON_SPONSORSHIPS_OUTPUT_PORT_TOKEN = 'GetSeasonSponsorshipsOutputPort_TOKEN'; diff --git a/apps/api/src/domain/league/dtos/AllLeaguesWithCapacityAndScoringDTO.ts b/apps/api/src/domain/league/dtos/AllLeaguesWithCapacityAndScoringDTO.ts index 5dec87d5b..b9eb4d39c 100644 --- a/apps/api/src/domain/league/dtos/AllLeaguesWithCapacityAndScoringDTO.ts +++ b/apps/api/src/domain/league/dtos/AllLeaguesWithCapacityAndScoringDTO.ts @@ -1,14 +1,124 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNumber, IsArray, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; -import { LeagueSummaryDTO } from './LeagueSummaryDTO'; +import { IsArray, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator'; + +export class LeagueCapacityAndScoringSocialLinksDTO { + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsString() + discordUrl?: string; + + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsString() + youtubeUrl?: string; + + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsString() + websiteUrl?: string; +} + +export class LeagueCapacityAndScoringSettingsDTO { + @ApiProperty() + @IsNumber() + maxDrivers!: number; + + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsNumber() + sessionDuration?: number; + + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsString() + qualifyingFormat?: string; +} + +export class LeagueCapacityAndScoringSummaryScoringDTO { + @ApiProperty() + @IsString() + gameId!: string; + + @ApiProperty() + @IsString() + gameName!: string; + + @ApiProperty() + @IsString() + primaryChampionshipType!: 'driver' | 'team' | 'nations' | 'trophy'; + + @ApiProperty() + @IsString() + scoringPresetId!: string; + + @ApiProperty() + @IsString() + scoringPresetName!: string; + + @ApiProperty() + @IsString() + dropPolicySummary!: string; + + @ApiProperty() + @IsString() + scoringPatternSummary!: string; +} + +export class LeagueWithCapacityAndScoringDTO { + @ApiProperty() + @IsString() + id!: string; + + @ApiProperty() + @IsString() + name!: string; + + @ApiProperty() + @IsString() + description!: string; + + @ApiProperty() + @IsString() + ownerId!: string; + + @ApiProperty() + @IsString() + createdAt!: string; + + @ApiProperty({ type: LeagueCapacityAndScoringSettingsDTO }) + @ValidateNested() + @Type(() => LeagueCapacityAndScoringSettingsDTO) + settings!: LeagueCapacityAndScoringSettingsDTO; + + @ApiProperty() + @IsNumber() + usedSlots!: number; + + @ApiProperty({ required: false, nullable: true, type: LeagueCapacityAndScoringSocialLinksDTO }) + @IsOptional() + @ValidateNested() + @Type(() => LeagueCapacityAndScoringSocialLinksDTO) + socialLinks?: LeagueCapacityAndScoringSocialLinksDTO; + + @ApiProperty({ required: false, nullable: true, type: LeagueCapacityAndScoringSummaryScoringDTO }) + @IsOptional() + @ValidateNested() + @Type(() => LeagueCapacityAndScoringSummaryScoringDTO) + scoring?: LeagueCapacityAndScoringSummaryScoringDTO; + + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsString() + timingSummary?: string; +} export class AllLeaguesWithCapacityAndScoringDTO { - @ApiProperty({ type: [LeagueSummaryDTO] }) + @ApiProperty({ type: [LeagueWithCapacityAndScoringDTO] }) @IsArray() @ValidateNested({ each: true }) - @Type(() => LeagueSummaryDTO) - leagues!: LeagueSummaryDTO[]; + @Type(() => LeagueWithCapacityAndScoringDTO) + leagues!: LeagueWithCapacityAndScoringDTO[]; @ApiProperty() @IsNumber() diff --git a/apps/api/src/domain/league/presenters/AllLeaguesWithCapacityAndScoringPresenter.ts b/apps/api/src/domain/league/presenters/AllLeaguesWithCapacityAndScoringPresenter.ts new file mode 100644 index 000000000..baa506480 --- /dev/null +++ b/apps/api/src/domain/league/presenters/AllLeaguesWithCapacityAndScoringPresenter.ts @@ -0,0 +1,97 @@ +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { GetAllLeaguesWithCapacityAndScoringResult } from '@core/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringUseCase'; +import type { + AllLeaguesWithCapacityAndScoringDTO, + LeagueWithCapacityAndScoringDTO, +} from '../dtos/AllLeaguesWithCapacityAndScoringDTO'; + +export class AllLeaguesWithCapacityAndScoringPresenter + implements UseCaseOutputPort +{ + private result: AllLeaguesWithCapacityAndScoringDTO | null = null; + + present(result: GetAllLeaguesWithCapacityAndScoringResult): void { + const leagues: LeagueWithCapacityAndScoringDTO[] = result.leagues.map((summary) => { + const timingSummary = summary.preset + ? formatTimingSummary(summary.preset.defaultTimings.mainRaceMinutes) + : undefined; + + return { + id: summary.league.id.toString(), + name: summary.league.name.toString(), + description: summary.league.description?.toString() || '', + ownerId: summary.league.ownerId.toString(), + createdAt: summary.league.createdAt.toDate().toISOString(), + settings: { + maxDrivers: summary.maxDrivers, + ...(summary.league.settings.sessionDuration !== undefined + ? { sessionDuration: summary.league.settings.sessionDuration } + : {}), + ...(summary.league.settings.qualifyingFormat !== undefined + ? { qualifyingFormat: summary.league.settings.qualifyingFormat.toString() } + : {}), + }, + usedSlots: summary.currentDrivers, + ...mapSocialLinks(summary.league.socialLinks), + ...(summary.scoringConfig && summary.game && summary.preset + ? { + scoring: { + gameId: summary.game.id.toString(), + gameName: summary.game.name.toString(), + primaryChampionshipType: summary.preset.primaryChampionshipType, + scoringPresetId: summary.scoringConfig.scoringPresetId?.toString() ?? 'custom', + scoringPresetName: summary.preset.name, + dropPolicySummary: summary.preset.dropPolicySummary, + scoringPatternSummary: summary.preset.sessionSummary, + }, + } + : {}), + ...(timingSummary ? { timingSummary } : {}), + }; + }); + + this.result = { + leagues, + totalCount: leagues.length, + }; + } + + getViewModel(): AllLeaguesWithCapacityAndScoringDTO { + if (!this.result) throw new Error('Presenter not presented'); + return this.result; + } +} + +function formatTimingSummary(mainRaceMinutes: number): string { + if (!Number.isFinite(mainRaceMinutes) || mainRaceMinutes <= 0) return ''; + + if (mainRaceMinutes >= 60 && mainRaceMinutes % 60 === 0) { + return `${mainRaceMinutes / 60}h Race`; + } + + return `${mainRaceMinutes}m Race`; +} + +type SocialLinksInput = { + discordUrl?: string | undefined; + youtubeUrl?: string | undefined; + websiteUrl?: string | undefined; +}; + +type SocialLinksOutput = { + discordUrl?: string; + youtubeUrl?: string; + websiteUrl?: string; +}; + +function mapSocialLinks(socialLinks: SocialLinksInput | undefined): { socialLinks: SocialLinksOutput } | {} { + if (!socialLinks) return {}; + + const mapped: SocialLinksOutput = { + ...(socialLinks.discordUrl ? { discordUrl: socialLinks.discordUrl } : {}), + ...(socialLinks.youtubeUrl ? { youtubeUrl: socialLinks.youtubeUrl } : {}), + ...(socialLinks.websiteUrl ? { websiteUrl: socialLinks.websiteUrl } : {}), + }; + + return Object.keys(mapped).length > 0 ? { socialLinks: mapped } : {}; +} \ No newline at end of file diff --git a/apps/api/src/domain/media/MediaController.ts b/apps/api/src/domain/media/MediaController.ts index cf8d015af..84ec32516 100644 --- a/apps/api/src/domain/media/MediaController.ts +++ b/apps/api/src/domain/media/MediaController.ts @@ -21,6 +21,76 @@ type RequestAvatarGenerationInput = RequestAvatarGenerationInputDTO; type UploadMediaInput = UploadMediaInputDTO; type UpdateAvatarInput = UpdateAvatarInputDTO; +function hashToHue(input: string): number { + let hash = 0; + for (let i = 0; i < input.length; i += 1) { + hash = (hash * 31 + input.charCodeAt(i)) | 0; + } + return Math.abs(hash) % 360; +} + +function escapeXml(input: string): string { + return input + .replaceAll('&', '\u0026amp;') + .replaceAll('<', '\u0026lt;') + .replaceAll('>', '\u0026gt;') + .replaceAll('"', '\u0026quot;') + .replaceAll("'", '\u0026apos;'); +} + +function deriveLeagueLabel(leagueId: string): string { + const digits = leagueId.match(/\d+/)?.[0]; + if (digits) return digits.slice(-2); + return leagueId.replaceAll(/[^a-zA-Z]/g, '').slice(0, 2).toUpperCase() || 'GP'; +} + +function buildLeagueLogoSvg(leagueId: string): string { + const hue = hashToHue(leagueId); + const label = escapeXml(deriveLeagueLabel(leagueId)); + const bg = `hsl(${hue} 70% 38%)`; + const border = `hsl(${hue} 70% 28%)`; + + return ` + + + + + + + + + ${label} +`; +} + +function buildLeagueCoverSvg(leagueId: string): string { + const hue = hashToHue(leagueId); + const title = escapeXml(leagueId); + const bg1 = `hsl(${hue} 70% 28%)`; + const bg2 = `hsl(${(hue + 35) % 360} 85% 35%)`; + + return ` + + + + + + + + + + + + + + + + + GridPilot League + ${title} +`; +} + @ApiTags('media') @Controller('media') export class MediaController { @@ -61,6 +131,34 @@ export class MediaController { } } + @Public() + @Get('leagues/:leagueId/logo') + @ApiOperation({ summary: 'Get league logo (placeholder)' }) + @ApiParam({ name: 'leagueId', description: 'League ID' }) + async getLeagueLogo( + @Param('leagueId') leagueId: string, + @Res() res: Response, + ): Promise { + const svg = buildLeagueLogoSvg(leagueId); + res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8'); + res.setHeader('Cache-Control', 'public, max-age=86400'); + res.status(HttpStatus.OK).send(svg); + } + + @Public() + @Get('leagues/:leagueId/cover') + @ApiOperation({ summary: 'Get league cover (placeholder)' }) + @ApiParam({ name: 'leagueId', description: 'League ID' }) + async getLeagueCover( + @Param('leagueId') leagueId: string, + @Res() res: Response, + ): Promise { + const svg = buildLeagueCoverSvg(leagueId); + res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8'); + res.setHeader('Cache-Control', 'public, max-age=86400'); + res.status(HttpStatus.OK).send(svg); + } + @Public() @Get(':mediaId') @ApiOperation({ summary: 'Get media by ID' }) diff --git a/apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts b/apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts index 0ae3e6c89..4a45c178a 100644 --- a/apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts +++ b/apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts @@ -15,6 +15,8 @@ import type { IPenaltyRepository } from '@core/racing/domain/repositories/IPenal import type { IProtestRepository } from '@core/racing/domain/repositories/IProtestRepository'; import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository'; import type { ISeasonSponsorshipRepository } from '@core/racing/domain/repositories/ISeasonSponsorshipRepository'; +import type { ILeagueScoringConfigRepository } from '@core/racing/domain/repositories/ILeagueScoringConfigRepository'; +import type { IGameRepository } from '@core/racing/domain/repositories/IGameRepository'; import type { ILeagueWalletRepository } from '@core/racing/domain/repositories/ILeagueWalletRepository'; import type { ITransactionRepository } from '@core/racing/domain/repositories/ITransactionRepository'; import type { ISponsorRepository } from '@core/racing/domain/repositories/ISponsorRepository'; @@ -38,6 +40,8 @@ import { InMemoryPenaltyRepository } from '@adapters/racing/persistence/inmemory import { InMemoryProtestRepository } from '@adapters/racing/persistence/inmemory/InMemoryProtestRepository'; import { InMemorySeasonRepository } from '@adapters/racing/persistence/inmemory/InMemorySeasonRepository'; import { InMemorySeasonSponsorshipRepository } from '@adapters/racing/persistence/inmemory/InMemorySeasonSponsorshipRepository'; +import { InMemoryLeagueScoringConfigRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueScoringConfigRepository'; +import { InMemoryGameRepository } from '@adapters/racing/persistence/inmemory/InMemoryGameRepository'; import { InMemoryLeagueWalletRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueWalletRepository'; import { InMemoryTransactionRepository } from '@adapters/racing/persistence/inmemory/InMemoryTransactionRepository'; import { InMemorySponsorRepository } from '@adapters/racing/persistence/inmemory/InMemorySponsorRepository'; @@ -57,6 +61,8 @@ export const PENALTY_REPOSITORY_TOKEN = 'IPenaltyRepository'; export const PROTEST_REPOSITORY_TOKEN = 'IProtestRepository'; export const SEASON_REPOSITORY_TOKEN = 'ISeasonRepository'; export const SEASON_SPONSORSHIP_REPOSITORY_TOKEN = 'ISeasonSponsorshipRepository'; +export const LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN = 'ILeagueScoringConfigRepository'; +export const GAME_REPOSITORY_TOKEN = 'IGameRepository'; export const LEAGUE_WALLET_REPOSITORY_TOKEN = 'ILeagueWalletRepository'; export const TRANSACTION_REPOSITORY_TOKEN = 'ITransactionRepository'; export const SPONSOR_REPOSITORY_TOKEN = 'ISponsorRepository'; @@ -137,6 +143,16 @@ export const SPONSORSHIP_REQUEST_REPOSITORY_TOKEN = 'ISponsorshipRequestReposito useFactory: (logger: Logger): ISeasonSponsorshipRepository => new InMemorySeasonSponsorshipRepository(logger), inject: ['Logger'], }, + { + provide: LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN, + useFactory: (logger: Logger): ILeagueScoringConfigRepository => new InMemoryLeagueScoringConfigRepository(logger), + inject: ['Logger'], + }, + { + provide: GAME_REPOSITORY_TOKEN, + useFactory: (logger: Logger): IGameRepository => new InMemoryGameRepository(logger), + inject: ['Logger'], + }, { provide: LEAGUE_WALLET_REPOSITORY_TOKEN, useFactory: (logger: Logger): ILeagueWalletRepository => new InMemoryLeagueWalletRepository(logger), @@ -177,6 +193,8 @@ export const SPONSORSHIP_REQUEST_REPOSITORY_TOKEN = 'ISponsorshipRequestReposito PROTEST_REPOSITORY_TOKEN, SEASON_REPOSITORY_TOKEN, SEASON_SPONSORSHIP_REPOSITORY_TOKEN, + LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN, + GAME_REPOSITORY_TOKEN, LEAGUE_WALLET_REPOSITORY_TOKEN, TRANSACTION_REPOSITORY_TOKEN, SPONSOR_REPOSITORY_TOKEN, diff --git a/apps/website/components/leagues/LeagueCard.tsx b/apps/website/components/leagues/LeagueCard.tsx index 974379028..976966da0 100644 --- a/apps/website/components/leagues/LeagueCard.tsx +++ b/apps/website/components/leagues/LeagueCard.tsx @@ -105,12 +105,12 @@ export default function LeagueCard({ league, onClick }: LeagueCardProps) {
{/* Cover Image */}
- {`${league.name} {/* Gradient Overlay */}
@@ -141,12 +141,14 @@ export default function LeagueCard({ league, onClick }: LeagueCardProps) { {/* Logo */}
- {`${league.name}
diff --git a/apps/website/components/leagues/LeagueHeader.tsx b/apps/website/components/leagues/LeagueHeader.tsx index 24c3093f0..489c42541 100644 --- a/apps/website/components/leagues/LeagueHeader.tsx +++ b/apps/website/components/leagues/LeagueHeader.tsx @@ -37,12 +37,14 @@ export default function LeagueHeader({
- {`${leagueName}
diff --git a/apps/website/lib/api/leagues/LeaguesApiClient.ts b/apps/website/lib/api/leagues/LeaguesApiClient.ts index 1966be070..9dac9e47e 100644 --- a/apps/website/lib/api/leagues/LeaguesApiClient.ts +++ b/apps/website/lib/api/leagues/LeaguesApiClient.ts @@ -11,6 +11,7 @@ import type { RaceDTO } from '../../types/generated/RaceDTO'; import type { GetLeagueAdminConfigOutputDTO } from '../../types/generated/GetLeagueAdminConfigOutputDTO'; import type { LeagueScoringPresetDTO } from '../../types/generated/LeagueScoringPresetDTO'; import type { LeagueSeasonSummaryDTO } from '../../types/generated/LeagueSeasonSummaryDTO'; +import type { AllLeaguesWithCapacityAndScoringDTO } from '../../types/AllLeaguesWithCapacityAndScoringDTO'; /** * Leagues API Client @@ -23,6 +24,11 @@ export class LeaguesApiClient extends BaseApiClient { return this.get('/leagues/all-with-capacity'); } + /** Get all leagues with capacity + scoring summary (for leagues page filters) */ + getAllWithCapacityAndScoring(): Promise { + return this.get('/leagues/all-with-capacity-and-scoring'); + } + /** Get total number of leagues */ getTotal(): Promise { return this.get('/leagues/total-leagues'); diff --git a/apps/website/lib/services/leagues/LeagueService.test.ts b/apps/website/lib/services/leagues/LeagueService.test.ts index f593ebb01..d4f33573a 100644 --- a/apps/website/lib/services/leagues/LeagueService.test.ts +++ b/apps/website/lib/services/leagues/LeagueService.test.ts @@ -18,6 +18,7 @@ describe('LeagueService', () => { beforeEach(() => { mockApiClient = { getAllWithCapacity: vi.fn(), + getAllWithCapacityAndScoring: vi.fn(), getStandings: vi.fn(), getTotal: vi.fn(), getSchedule: vi.fn(), @@ -30,7 +31,7 @@ describe('LeagueService', () => { }); describe('getAllLeagues', () => { - it('should call apiClient.getAllWithCapacity and return array of LeagueSummaryViewModel', async () => { + it('should call apiClient.getAllWithCapacityAndScoring and return array of LeagueSummaryViewModel', async () => { const mockDto = { totalCount: 2, leagues: [ @@ -39,11 +40,11 @@ describe('LeagueService', () => { ], } as any; - mockApiClient.getAllWithCapacity.mockResolvedValue(mockDto); + mockApiClient.getAllWithCapacityAndScoring.mockResolvedValue(mockDto); const result = await service.getAllLeagues(); - expect(mockApiClient.getAllWithCapacity).toHaveBeenCalled(); + expect(mockApiClient.getAllWithCapacityAndScoring).toHaveBeenCalled(); expect(result).toHaveLength(2); expect(result.map((l) => l.id)).toEqual(['league-1', 'league-2']); }); @@ -51,16 +52,16 @@ describe('LeagueService', () => { it('should handle empty leagues array', async () => { const mockDto = { totalCount: 0, leagues: [] } as any; - mockApiClient.getAllWithCapacity.mockResolvedValue(mockDto); + mockApiClient.getAllWithCapacityAndScoring.mockResolvedValue(mockDto); const result = await service.getAllLeagues(); expect(result).toHaveLength(0); }); - it('should throw error when apiClient.getAllWithCapacity fails', async () => { + it('should throw error when apiClient.getAllWithCapacityAndScoring fails', async () => { const error = new Error('API call failed'); - mockApiClient.getAllWithCapacity.mockRejectedValue(error); + mockApiClient.getAllWithCapacityAndScoring.mockRejectedValue(error); await expect(service.getAllLeagues()).rejects.toThrow('API call failed'); }); diff --git a/apps/website/lib/services/leagues/LeagueService.ts b/apps/website/lib/services/leagues/LeagueService.ts index 7b3dc3e2e..1dcf2e5ad 100644 --- a/apps/website/lib/services/leagues/LeagueService.ts +++ b/apps/website/lib/services/leagues/LeagueService.ts @@ -44,17 +44,20 @@ export class LeagueService { * Get all leagues with view model transformation */ async getAllLeagues(): Promise { - const dto = await this.apiClient.getAllWithCapacity(); - return dto.leagues.map((league: LeagueWithCapacityDTO) => ({ + const dto = await this.apiClient.getAllWithCapacityAndScoring(); + + return dto.leagues.map((league) => ({ id: league.id, name: league.name, - description: (league as any).description ?? '', - ownerId: (league as any).ownerId ?? '', - createdAt: (league as any).createdAt ?? '', - maxDrivers: (league as any).settings?.maxDrivers ?? 0, - usedDriverSlots: (league as any).usedSlots ?? 0, - structureSummary: 'TBD', - timingSummary: 'TBD' + description: league.description, + ownerId: league.ownerId, + createdAt: league.createdAt, + maxDrivers: league.settings?.maxDrivers ?? 0, + usedDriverSlots: league.usedSlots ?? 0, + structureSummary: league.scoring?.scoringPresetName ?? 'Custom rules', + scoringPatternSummary: league.scoring?.scoringPatternSummary, + timingSummary: league.timingSummary ?? '', + ...(league.scoring ? { scoring: league.scoring } : {}), })); } @@ -162,16 +165,16 @@ export class LeagueService { // For now, assume league data comes from getAllWithCapacity or a new endpoint // Since API may not have detailed league, we'll mock or assume // In real implementation, add getLeagueDetail to API - const allLeagues = await this.apiClient.getAllWithCapacity(); - const leagueDto = allLeagues.leagues.find((l: any) => l.id === leagueId); + const allLeagues = await this.apiClient.getAllWithCapacityAndScoring(); + const leagueDto = allLeagues.leagues.find((l) => l.id === leagueId); if (!leagueDto) return null; // LeagueWithCapacityDTO already carries core fields; fall back to placeholder description/owner when not provided const league = { id: leagueDto.id, name: leagueDto.name, - description: (leagueDto as any).description ?? 'Description not available', - ownerId: (leagueDto as any).ownerId ?? 'owner-id', + description: leagueDto.description ?? 'Description not available', + ownerId: leagueDto.ownerId ?? 'owner-id', }; // Get owner @@ -245,12 +248,12 @@ export class LeagueService { try { // Get league basic info - const allLeagues = await this.apiClient.getAllWithCapacity(); - const league = allLeagues.leagues.find((l: any) => l.id === leagueId); + const allLeagues = await this.apiClient.getAllWithCapacityAndScoring(); + const league = allLeagues.leagues.find((l) => l.id === leagueId); if (!league) return null; // Get owner - const owner = await this.driversApiClient.getDriver((league as any).ownerId); + const owner = await this.driversApiClient.getDriver(league.ownerId); // League scoring configuration is not exposed separately yet; use null to indicate "not configured" in the UI const scoringConfig: LeagueScoringConfigDTO | null = null; diff --git a/apps/website/lib/types/AllLeaguesWithCapacityAndScoringDTO.ts b/apps/website/lib/types/AllLeaguesWithCapacityAndScoringDTO.ts new file mode 100644 index 000000000..64422942d --- /dev/null +++ b/apps/website/lib/types/AllLeaguesWithCapacityAndScoringDTO.ts @@ -0,0 +1,45 @@ +export type LeagueCapacityAndScoringPrimaryChampionshipType = + | 'driver' + | 'team' + | 'nations' + | 'trophy'; + +export type LeagueCapacityAndScoringSummaryScoringDTO = { + gameId: string; + gameName: string; + primaryChampionshipType: LeagueCapacityAndScoringPrimaryChampionshipType; + scoringPresetId: string; + scoringPresetName: string; + dropPolicySummary: string; + scoringPatternSummary: string; +}; + +export type LeagueCapacityAndScoringSocialLinksDTO = { + discordUrl?: string; + youtubeUrl?: string; + websiteUrl?: string; +}; + +export type LeagueCapacityAndScoringSettingsDTO = { + maxDrivers: number; + sessionDuration?: number; + qualifyingFormat?: string; +}; + +export type LeagueWithCapacityAndScoringDTO = { + id: string; + name: string; + description: string; + ownerId: string; + createdAt: string; + settings: LeagueCapacityAndScoringSettingsDTO; + usedSlots: number; + socialLinks?: LeagueCapacityAndScoringSocialLinksDTO; + scoring?: LeagueCapacityAndScoringSummaryScoringDTO; + timingSummary?: string; +}; + +export type AllLeaguesWithCapacityAndScoringDTO = { + leagues: LeagueWithCapacityAndScoringDTO[]; + totalCount: number; +}; \ No newline at end of file diff --git a/apps/website/next.config.mjs b/apps/website/next.config.mjs index 2f03ed077..51abda614 100644 --- a/apps/website/next.config.mjs +++ b/apps/website/next.config.mjs @@ -20,6 +20,24 @@ const nextConfig = { hostname: 'picsum.photos', }, ], + dangerouslyAllowSVG: true, + contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;", + contentDispositionType: 'inline', + }, + async rewrites() { + const rawBaseUrl = + process.env.API_BASE_URL ?? + process.env.NEXT_PUBLIC_API_BASE_URL ?? + 'http://localhost:3001'; + + const baseUrl = rawBaseUrl.endsWith('/') ? rawBaseUrl.slice(0, -1) : rawBaseUrl; + + return [ + { + source: '/api/media/:path*', + destination: `${baseUrl}/media/:path*`, + }, + ]; }, typescript: { ignoreBuildErrors: false, diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 1f5eef0b0..e4444223d 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -28,6 +28,7 @@ services: build: context: . dockerfile: apps/api/Dockerfile.dev + working_dir: /app/apps/api env_file: - .env.development environment: @@ -40,7 +41,7 @@ services: - ./:/app - dev_node_modules:/app/node_modules - dev_npm_cache:/root/.npm - command: ["sh", "-lc", "npm run start:dev --workspace=@gridpilot/api"] + command: ["sh", "-lc", "npm run start:dev"] depends_on: deps: condition: service_completed_successfully