more seeds

This commit is contained in:
2025-12-27 11:58:35 +01:00
parent 91612e4256
commit 3efa978ee0
25 changed files with 806 additions and 55 deletions

View File

@@ -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<SessionType, PointsTable> = {
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<SessionType, BonusRule[]> = {
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<SessionType, PointsTable> = {
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<SessionType, PointsTable> = {
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[] { export function listLeagueScoringPresets(): LeagueScoringPreset[] {

View File

@@ -10,10 +10,13 @@ import type { ITeamRepository } from '@core/racing/domain/repositories/ITeamRepo
import type { ITeamMembershipRepository } from '@core/racing/domain/repositories/ITeamMembershipRepository'; import type { ITeamMembershipRepository } from '@core/racing/domain/repositories/ITeamMembershipRepository';
import type { ISponsorRepository } from '@core/racing/domain/repositories/ISponsorRepository'; import type { ISponsorRepository } from '@core/racing/domain/repositories/ISponsorRepository';
import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository'; 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 { ISeasonSponsorshipRepository } from '@core/racing/domain/repositories/ISeasonSponsorshipRepository';
import type { ISponsorshipRequestRepository } from '@core/racing/domain/repositories/ISponsorshipRequestRepository'; import type { ISponsorshipRequestRepository } from '@core/racing/domain/repositories/ISponsorshipRequestRepository';
import type { ILeagueWalletRepository } from '@core/racing/domain/repositories/ILeagueWalletRepository'; import type { ILeagueWalletRepository } from '@core/racing/domain/repositories/ILeagueWalletRepository';
import type { ITransactionRepository } from '@core/racing/domain/repositories/ITransactionRepository'; 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 { IProtestRepository } from '@core/racing/domain/repositories/IProtestRepository';
import type { IPenaltyRepository } from '@core/racing/domain/repositories/IPenaltyRepository'; import type { IPenaltyRepository } from '@core/racing/domain/repositories/IPenaltyRepository';
import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository'; import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository';
@@ -24,6 +27,7 @@ export type RacingSeedDependencies = {
driverRepository: IDriverRepository; driverRepository: IDriverRepository;
leagueRepository: ILeagueRepository; leagueRepository: ILeagueRepository;
seasonRepository: ISeasonRepository; seasonRepository: ISeasonRepository;
leagueScoringConfigRepository: ILeagueScoringConfigRepository;
seasonSponsorshipRepository: ISeasonSponsorshipRepository; seasonSponsorshipRepository: ISeasonSponsorshipRepository;
sponsorshipRequestRepository: ISponsorshipRequestRepository; sponsorshipRequestRepository: ISponsorshipRequestRepository;
leagueWalletRepository: ILeagueWalletRepository; leagueWalletRepository: ILeagueWalletRepository;
@@ -51,7 +55,8 @@ export class SeedRacingData {
async execute(): Promise<void> { async execute(): Promise<void> {
const existingDrivers = await this.seedDeps.driverRepository.findAll(); const existingDrivers = await this.seedDeps.driverRepository.findAll();
if (existingDrivers.length > 0) { 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; 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) { for (const sponsorship of seed.seasonSponsorships) {
try { try {
@@ -239,4 +264,64 @@ export class SeedRacingData {
`[Bootstrap] Seeded racing data: drivers=${seed.drivers.length}, leagues=${seed.leagues.length}, races=${seed.races.length}`, `[Bootstrap] Seeded racing data: drivers=${seed.drivers.length}, leagues=${seed.leagues.length}, races=${seed.races.length}`,
); );
} }
private async ensureScoringConfigsForExistingData(): Promise<void> {
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';
}
} }

View File

@@ -33,6 +33,12 @@ export class RacingLeagueFactory {
const owner = faker.helpers.arrayElement(this.drivers); const owner = faker.helpers.arrayElement(this.drivers);
const config = leagueConfigs[idx % leagueConfigs.length]!; 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: { const leagueData: {
id: string; id: string;
name: string; name: string;
@@ -52,7 +58,7 @@ export class RacingLeagueFactory {
description: faker.lorem.sentences(2), description: faker.lorem.sentences(2),
ownerId: owner.id.toString(), ownerId: owner.id.toString(),
settings: config, settings: config,
createdAt: faker.date.past({ years: 2, refDate: this.baseDate }), createdAt,
}; };
// Add social links with varying completeness // Add social links with varying completeness

View File

@@ -68,7 +68,7 @@ export const racingSeedDefaults: Readonly<
Required<RacingSeedOptions> Required<RacingSeedOptions>
> = { > = {
driverCount: 100, driverCount: 100,
baseDate: new Date('2025-01-15T12:00:00.000Z'), baseDate: new Date(),
}; };
class RacingSeedFactory { class RacingSeedFactory {

View File

@@ -33,9 +33,13 @@ describe('InMemoryGameRepository', () => {
}); });
describe('findAll', () => { describe('findAll', () => {
it('should return an empty array', async () => { it('should return default seeded games', async () => {
const result = await repository.findAll(); 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.'); expect(mockLogger.debug).toHaveBeenCalledWith('[InMemoryGameRepository] Finding all games.');
}); });
}); });

View File

@@ -7,6 +7,20 @@ export class InMemoryGameRepository implements IGameRepository {
constructor(private readonly logger: Logger) { constructor(private readonly logger: Logger) {
this.logger.info('InMemoryGameRepository initialized.'); 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<Game | null> { async findById(id: string): Promise<Game | null> {

View File

@@ -8,7 +8,7 @@ describe('InMemoryLeagueScoringPresetProvider', () => {
it('should return all available presets', () => { it('should return all available presets', () => {
const presets = provider.listPresets(); const presets = provider.listPresets();
expect(presets).toHaveLength(3); expect(presets.length).toBeGreaterThanOrEqual(3);
expect(presets).toEqual( expect(presets).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({

View File

@@ -47,6 +47,7 @@ export const BootstrapProviders: Provider[] = [
driverRepository: RacingSeedDependencies['driverRepository'], driverRepository: RacingSeedDependencies['driverRepository'],
leagueRepository: RacingSeedDependencies['leagueRepository'], leagueRepository: RacingSeedDependencies['leagueRepository'],
seasonRepository: RacingSeedDependencies['seasonRepository'], seasonRepository: RacingSeedDependencies['seasonRepository'],
leagueScoringConfigRepository: RacingSeedDependencies['leagueScoringConfigRepository'],
seasonSponsorshipRepository: RacingSeedDependencies['seasonSponsorshipRepository'], seasonSponsorshipRepository: RacingSeedDependencies['seasonSponsorshipRepository'],
sponsorshipRequestRepository: RacingSeedDependencies['sponsorshipRequestRepository'], sponsorshipRequestRepository: RacingSeedDependencies['sponsorshipRequestRepository'],
leagueWalletRepository: RacingSeedDependencies['leagueWalletRepository'], leagueWalletRepository: RacingSeedDependencies['leagueWalletRepository'],
@@ -67,6 +68,7 @@ export const BootstrapProviders: Provider[] = [
driverRepository, driverRepository,
leagueRepository, leagueRepository,
seasonRepository, seasonRepository,
leagueScoringConfigRepository,
seasonSponsorshipRepository, seasonSponsorshipRepository,
sponsorshipRequestRepository, sponsorshipRequestRepository,
leagueWalletRepository, leagueWalletRepository,
@@ -88,6 +90,7 @@ export const BootstrapProviders: Provider[] = [
'IDriverRepository', 'IDriverRepository',
'ILeagueRepository', 'ILeagueRepository',
'ISeasonRepository', 'ISeasonRepository',
'ILeagueScoringConfigRepository',
'ISeasonSponsorshipRepository', 'ISeasonSponsorshipRepository',
'ISponsorshipRequestRepository', 'ISponsorshipRequestRepository',
'ILeagueWalletRepository', 'ILeagueWalletRepository',

View File

@@ -3,6 +3,7 @@ import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Public } from '../auth/Public'; import { Public } from '../auth/Public';
import { LeagueService } from './LeagueService'; import { LeagueService } from './LeagueService';
import { AllLeaguesWithCapacityDTO } from './dtos/AllLeaguesWithCapacityDTO'; import { AllLeaguesWithCapacityDTO } from './dtos/AllLeaguesWithCapacityDTO';
import { AllLeaguesWithCapacityAndScoringDTO } from './dtos/AllLeaguesWithCapacityAndScoringDTO';
import { ApproveJoinRequestInputDTO } from './dtos/ApproveJoinRequestInputDTO'; import { ApproveJoinRequestInputDTO } from './dtos/ApproveJoinRequestInputDTO';
import { ApproveJoinRequestOutputDTO } from './dtos/ApproveJoinRequestOutputDTO'; import { ApproveJoinRequestOutputDTO } from './dtos/ApproveJoinRequestOutputDTO';
import { CreateLeagueInputDTO } from './dtos/CreateLeagueInputDTO'; import { CreateLeagueInputDTO } from './dtos/CreateLeagueInputDTO';
@@ -49,6 +50,14 @@ export class LeagueController {
return this.leagueService.getAllLeaguesWithCapacity(); 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<AllLeaguesWithCapacityAndScoringDTO> {
return this.leagueService.getAllLeaguesWithCapacityAndScoring();
}
@Public() @Public()
@Get('total-leagues') @Get('total-leagues')
@ApiOperation({ summary: 'Get the total number of leagues' }) @ApiOperation({ summary: 'Get the total number of leagues' })

View File

@@ -15,16 +15,15 @@ import type { Logger } from '@core/shared/application/Logger';
// Import concrete in-memory implementations // Import concrete in-memory implementations
import type { ILeagueWalletRepository } from "@core/racing/domain/repositories/ILeagueWalletRepository"; import type { ILeagueWalletRepository } from "@core/racing/domain/repositories/ILeagueWalletRepository";
import type { ITransactionRepository } from "@core/racing/domain/repositories/ITransactionRepository"; 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 { 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 { InMemoryLeagueStandingsRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueStandingsRepository';
// Import use cases // Import use cases
import { ApproveLeagueJoinRequestUseCase } from '@core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase'; import { ApproveLeagueJoinRequestUseCase } from '@core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase';
import { CreateLeagueWithSeasonAndScoringUseCase } from '@core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase'; import { CreateLeagueWithSeasonAndScoringUseCase } from '@core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase';
import { GetAllLeaguesWithCapacityUseCase } from '@core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase'; 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 { GetLeagueAdminPermissionsUseCase } from '@core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase';
import { GetLeagueFullConfigUseCase } from '@core/racing/application/use-cases/GetLeagueFullConfigUseCase'; import { GetLeagueFullConfigUseCase } from '@core/racing/application/use-cases/GetLeagueFullConfigUseCase';
import { GetLeagueJoinRequestsUseCase } from '@core/racing/application/use-cases/GetLeagueJoinRequestsUseCase'; import { GetLeagueJoinRequestsUseCase } from '@core/racing/application/use-cases/GetLeagueJoinRequestsUseCase';
@@ -50,6 +49,7 @@ import { WithdrawFromLeagueWalletUseCase } from '@core/racing/application/use-ca
// Import presenters // Import presenters
import { AllLeaguesWithCapacityPresenter } from './presenters/AllLeaguesWithCapacityPresenter'; import { AllLeaguesWithCapacityPresenter } from './presenters/AllLeaguesWithCapacityPresenter';
import { AllLeaguesWithCapacityAndScoringPresenter } from './presenters/AllLeaguesWithCapacityAndScoringPresenter';
import { ApproveLeagueJoinRequestPresenter } from './presenters/ApproveLeagueJoinRequestPresenter'; import { ApproveLeagueJoinRequestPresenter } from './presenters/ApproveLeagueJoinRequestPresenter';
import { CreateLeaguePresenter } from './presenters/CreateLeaguePresenter'; import { CreateLeaguePresenter } from './presenters/CreateLeaguePresenter';
import { GetLeagueAdminPermissionsPresenter } from './presenters/GetLeagueAdminPermissionsPresenter'; import { GetLeagueAdminPermissionsPresenter } from './presenters/GetLeagueAdminPermissionsPresenter';
@@ -89,6 +89,7 @@ export const TRANSACTION_REPOSITORY_TOKEN = 'ITransactionRepository';
export const LOGGER_TOKEN = 'Logger'; export const LOGGER_TOKEN = 'Logger';
export const GET_LEAGUE_STANDINGS_USE_CASE = 'GetLeagueStandingsUseCase'; 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_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_STATS_USE_CASE = 'GetLeagueStatsUseCase';
export const GET_LEAGUE_FULL_CONFIG_USE_CASE = 'GetLeagueFullConfigUseCase'; export const GET_LEAGUE_FULL_CONFIG_USE_CASE = 'GetLeagueFullConfigUseCase';
export const GET_LEAGUE_SCORING_CONFIG_USE_CASE = 'GetLeagueScoringConfigUseCase'; 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_SEASON_SPONSORSHIPS_USE_CASE = 'GetSeasonSponsorshipsUseCase';
export const GET_ALL_LEAGUES_WITH_CAPACITY_OUTPUT_PORT_TOKEN = 'GetAllLeaguesWithCapacityOutputPort_TOKEN'; 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_STANDINGS_OUTPUT_PORT_TOKEN = 'GetLeagueStandingsOutputPort_TOKEN';
export const GET_LEAGUE_PROTESTS_OUTPUT_PORT_TOKEN = 'GetLeagueProtestsOutputPort_TOKEN'; export const GET_LEAGUE_PROTESTS_OUTPUT_PORT_TOKEN = 'GetLeagueProtestsOutputPort_TOKEN';
export const GET_SEASON_SPONSORSHIPS_OUTPUT_PORT_TOKEN = 'GetSeasonSponsorshipsOutputPort_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 useFactory: (logger: Logger) => new InMemoryLeagueStandingsRepository(logger), // Factory for InMemoryLeagueStandingsRepository
inject: [LOGGER_TOKEN], 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, provide: LOGGER_TOKEN,
useClass: ConsoleLogger, useClass: ConsoleLogger,
}, },
// Presenters // Presenters
AllLeaguesWithCapacityPresenter, AllLeaguesWithCapacityPresenter,
AllLeaguesWithCapacityAndScoringPresenter,
ApproveLeagueJoinRequestPresenter, ApproveLeagueJoinRequestPresenter,
CreateLeaguePresenter, CreateLeaguePresenter,
GetLeagueAdminPermissionsPresenter, GetLeagueAdminPermissionsPresenter,
@@ -189,6 +182,10 @@ export const LeagueProviders: Provider[] = [
provide: GET_ALL_LEAGUES_WITH_CAPACITY_OUTPUT_PORT_TOKEN, provide: GET_ALL_LEAGUES_WITH_CAPACITY_OUTPUT_PORT_TOKEN,
useExisting: AllLeaguesWithCapacityPresenter, useExisting: AllLeaguesWithCapacityPresenter,
}, },
{
provide: GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_OUTPUT_PORT_TOKEN,
useExisting: AllLeaguesWithCapacityAndScoringPresenter,
},
{ {
provide: GET_LEAGUE_STANDINGS_OUTPUT_PORT_TOKEN, provide: GET_LEAGUE_STANDINGS_OUTPUT_PORT_TOKEN,
useExisting: LeagueStandingsPresenter, useExisting: LeagueStandingsPresenter,
@@ -284,6 +281,34 @@ export const LeagueProviders: Provider[] = [
new GetAllLeaguesWithCapacityUseCase(leagueRepo, membershipRepo), new GetAllLeaguesWithCapacityUseCase(leagueRepo, membershipRepo),
inject: [LEAGUE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN], 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, provide: GET_LEAGUE_STANDINGS_USE_CASE,
useFactory: ( useFactory: (

View File

@@ -10,6 +10,7 @@ describe('LeagueService', () => {
const err = async () => Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } }); const err = async () => Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } });
const getAllLeaguesWithCapacityUseCase: any = { execute: vi.fn(async () => Result.ok({ leagues: [] })) }; const getAllLeaguesWithCapacityUseCase: any = { execute: vi.fn(async () => Result.ok({ leagues: [] })) };
const getAllLeaguesWithCapacityAndScoringUseCase: any = { execute: vi.fn(ok) };
const getLeagueStandingsUseCase = { execute: vi.fn(ok) }; const getLeagueStandingsUseCase = { execute: vi.fn(ok) };
const getLeagueStatsUseCase = { execute: vi.fn(ok) }; const getLeagueStatsUseCase = { execute: vi.fn(ok) };
const getLeagueFullConfigUseCase: any = { execute: vi.fn(ok) }; const getLeagueFullConfigUseCase: any = { execute: vi.fn(ok) };
@@ -35,6 +36,10 @@ describe('LeagueService', () => {
const getSeasonSponsorshipsUseCase = { execute: vi.fn(ok) }; const getSeasonSponsorshipsUseCase = { execute: vi.fn(ok) };
const allLeaguesWithCapacityPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ leagues: [] })) }; 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 leagueStandingsPresenter = { getResponseModel: vi.fn(() => ({ standings: [] })) };
const leagueProtestsPresenter = { getResponseModel: vi.fn(() => ({ protests: [] })) }; const leagueProtestsPresenter = { getResponseModel: vi.fn(() => ({ protests: [] })) };
const seasonSponsorshipsPresenter = { getViewModel: vi.fn(() => ({ sponsorships: [] })) }; const seasonSponsorshipsPresenter = { getViewModel: vi.fn(() => ({ sponsorships: [] })) };
@@ -62,6 +67,7 @@ describe('LeagueService', () => {
const service = new LeagueService( const service = new LeagueService(
getAllLeaguesWithCapacityUseCase as any, getAllLeaguesWithCapacityUseCase as any,
getAllLeaguesWithCapacityAndScoringUseCase as any,
getLeagueStandingsUseCase as any, getLeagueStandingsUseCase as any,
getLeagueStatsUseCase as any, getLeagueStatsUseCase as any,
getLeagueFullConfigUseCase as any, getLeagueFullConfigUseCase as any,
@@ -87,6 +93,7 @@ describe('LeagueService', () => {
getSeasonSponsorshipsUseCase as any, getSeasonSponsorshipsUseCase as any,
logger as any, logger as any,
allLeaguesWithCapacityPresenter as any, allLeaguesWithCapacityPresenter as any,
allLeaguesWithCapacityAndScoringPresenter as any,
leagueStandingsPresenter as any, leagueStandingsPresenter as any,
leagueProtestsPresenter as any, leagueProtestsPresenter as any,
seasonSponsorshipsPresenter as any, seasonSponsorshipsPresenter as any,
@@ -149,6 +156,7 @@ describe('LeagueService', () => {
}); });
await expect(service.getAllLeaguesWithCapacity()).resolves.toEqual({ leagues: [] }); await expect(service.getAllLeaguesWithCapacity()).resolves.toEqual({ leagues: [] });
await expect(service.getAllLeaguesWithCapacityAndScoring()).resolves.toEqual({ leagues: [], totalCount: 0 });
// Error branch: getAllLeaguesWithCapacity throws on result.isErr() // Error branch: getAllLeaguesWithCapacity throws on result.isErr()
getAllLeaguesWithCapacityUseCase.execute.mockResolvedValueOnce(Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } })); getAllLeaguesWithCapacityUseCase.execute.mockResolvedValueOnce(Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } }));

View File

@@ -33,6 +33,7 @@ import { WithdrawFromLeagueWalletOutputDTO } from './dtos/WithdrawFromLeagueWall
// Core imports for view models // Core imports for view models
import type { AllLeaguesWithCapacityDTO as AllLeaguesWithCapacityViewModel } from './dtos/AllLeaguesWithCapacityDTO'; import type { AllLeaguesWithCapacityDTO as AllLeaguesWithCapacityViewModel } from './dtos/AllLeaguesWithCapacityDTO';
import type { AllLeaguesWithCapacityAndScoringDTO as AllLeaguesWithCapacityAndScoringViewModel } from './dtos/AllLeaguesWithCapacityAndScoringDTO';
import type { CreateLeagueViewModel } from './dtos/CreateLeagueDTO'; import type { CreateLeagueViewModel } from './dtos/CreateLeagueDTO';
import type { JoinLeagueOutputDTO } from './dtos/JoinLeagueOutputDTO'; import type { JoinLeagueOutputDTO } from './dtos/JoinLeagueOutputDTO';
import { TotalLeaguesDTO } from './dtos/TotalLeaguesDTO'; 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 { ApproveLeagueJoinRequestUseCase } from '@core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase';
import { CreateLeagueWithSeasonAndScoringUseCase } from '@core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase'; import { CreateLeagueWithSeasonAndScoringUseCase } from '@core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase';
import { GetAllLeaguesWithCapacityUseCase } from '@core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase'; 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 { GetLeagueAdminPermissionsUseCase } from '@core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase';
import { GetLeagueFullConfigUseCase } from '@core/racing/application/use-cases/GetLeagueFullConfigUseCase'; import { GetLeagueFullConfigUseCase } from '@core/racing/application/use-cases/GetLeagueFullConfigUseCase';
import { GetLeagueJoinRequestsUseCase } from '@core/racing/application/use-cases/GetLeagueJoinRequestsUseCase'; import { GetLeagueJoinRequestsUseCase } from '@core/racing/application/use-cases/GetLeagueJoinRequestsUseCase';
@@ -70,6 +72,7 @@ import { WithdrawFromLeagueWalletUseCase } from '@core/racing/application/use-ca
// API Presenters // API Presenters
import { AllLeaguesWithCapacityPresenter } from './presenters/AllLeaguesWithCapacityPresenter'; import { AllLeaguesWithCapacityPresenter } from './presenters/AllLeaguesWithCapacityPresenter';
import { AllLeaguesWithCapacityAndScoringPresenter } from './presenters/AllLeaguesWithCapacityAndScoringPresenter';
import { ApproveLeagueJoinRequestPresenter } from './presenters/ApproveLeagueJoinRequestPresenter'; import { ApproveLeagueJoinRequestPresenter } from './presenters/ApproveLeagueJoinRequestPresenter';
import { CreateLeaguePresenter } from './presenters/CreateLeaguePresenter'; import { CreateLeaguePresenter } from './presenters/CreateLeaguePresenter';
import { GetLeagueAdminPermissionsPresenter } from './presenters/GetLeagueAdminPermissionsPresenter'; import { GetLeagueAdminPermissionsPresenter } from './presenters/GetLeagueAdminPermissionsPresenter';
@@ -98,6 +101,7 @@ import {
LOGGER_TOKEN, LOGGER_TOKEN,
GET_ALL_LEAGUES_WITH_CAPACITY_USE_CASE, GET_ALL_LEAGUES_WITH_CAPACITY_USE_CASE,
GET_LEAGUE_STANDINGS_USE_CASE, GET_LEAGUE_STANDINGS_USE_CASE,
GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_USE_CASE,
GET_LEAGUE_STATS_USE_CASE, GET_LEAGUE_STATS_USE_CASE,
GET_LEAGUE_FULL_CONFIG_USE_CASE, GET_LEAGUE_FULL_CONFIG_USE_CASE,
GET_LEAGUE_SCORING_CONFIG_USE_CASE, GET_LEAGUE_SCORING_CONFIG_USE_CASE,
@@ -121,6 +125,7 @@ import {
WITHDRAW_FROM_LEAGUE_WALLET_USE_CASE, WITHDRAW_FROM_LEAGUE_WALLET_USE_CASE,
GET_SEASON_SPONSORSHIPS_USE_CASE, GET_SEASON_SPONSORSHIPS_USE_CASE,
GET_ALL_LEAGUES_WITH_CAPACITY_OUTPUT_PORT_TOKEN, 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_STANDINGS_OUTPUT_PORT_TOKEN,
GET_LEAGUE_PROTESTS_OUTPUT_PORT_TOKEN, GET_LEAGUE_PROTESTS_OUTPUT_PORT_TOKEN,
GET_SEASON_SPONSORSHIPS_OUTPUT_PORT_TOKEN, GET_SEASON_SPONSORSHIPS_OUTPUT_PORT_TOKEN,
@@ -149,6 +154,7 @@ import {
export class LeagueService { export class LeagueService {
constructor( constructor(
@Inject(GET_ALL_LEAGUES_WITH_CAPACITY_USE_CASE) private readonly getAllLeaguesWithCapacityUseCase: GetAllLeaguesWithCapacityUseCase, @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_STANDINGS_USE_CASE) private readonly getLeagueStandingsUseCase: GetLeagueStandingsUseCase,
@Inject(GET_LEAGUE_STATS_USE_CASE) private readonly getLeagueStatsUseCase: GetLeagueStatsUseCase, @Inject(GET_LEAGUE_STATS_USE_CASE) private readonly getLeagueStatsUseCase: GetLeagueStatsUseCase,
@Inject(GET_LEAGUE_FULL_CONFIG_USE_CASE) private readonly getLeagueFullConfigUseCase: GetLeagueFullConfigUseCase, @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, @Inject(LOGGER_TOKEN) private readonly logger: Logger,
// Injected presenters // Injected presenters
@Inject(GET_ALL_LEAGUES_WITH_CAPACITY_OUTPUT_PORT_TOKEN) private readonly allLeaguesWithCapacityPresenter: AllLeaguesWithCapacityPresenter, @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_STANDINGS_OUTPUT_PORT_TOKEN) private readonly leagueStandingsPresenter: LeagueStandingsPresenter,
@Inject(GET_LEAGUE_PROTESTS_OUTPUT_PORT_TOKEN) private readonly leagueProtestsPresenter: GetLeagueProtestsPresenter, @Inject(GET_LEAGUE_PROTESTS_OUTPUT_PORT_TOKEN) private readonly leagueProtestsPresenter: GetLeagueProtestsPresenter,
@Inject(GET_SEASON_SPONSORSHIPS_OUTPUT_PORT_TOKEN) private readonly seasonSponsorshipsPresenter: GetSeasonSponsorshipsPresenter, @Inject(GET_SEASON_SPONSORSHIPS_OUTPUT_PORT_TOKEN) private readonly seasonSponsorshipsPresenter: GetSeasonSponsorshipsPresenter,
@@ -218,6 +225,22 @@ export class LeagueService {
return this.allLeaguesWithCapacityPresenter.getViewModel(); return this.allLeaguesWithCapacityPresenter.getViewModel();
} }
async getAllLeaguesWithCapacityAndScoring(): Promise<AllLeaguesWithCapacityAndScoringViewModel> {
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<TotalLeaguesDTO> { async getTotalLeagues(): Promise<TotalLeaguesDTO> {
this.logger.debug('[LeagueService] Fetching total leagues count.'); this.logger.debug('[LeagueService] Fetching total leagues count.');
await this.getTotalLeaguesUseCase.execute({}); await this.getTotalLeaguesUseCase.execute({});

View File

@@ -14,6 +14,7 @@ export const LOGGER_TOKEN = 'Logger';
export const GET_LEAGUE_STANDINGS_USE_CASE = 'GetLeagueStandingsUseCase'; 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_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_STATS_USE_CASE = 'GetLeagueStatsUseCase';
export const GET_LEAGUE_FULL_CONFIG_USE_CASE = 'GetLeagueFullConfigUseCase'; export const GET_LEAGUE_FULL_CONFIG_USE_CASE = 'GetLeagueFullConfigUseCase';
export const GET_LEAGUE_SCORING_CONFIG_USE_CASE = 'GetLeagueScoringConfigUseCase'; 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_SEASON_SPONSORSHIPS_USE_CASE = 'GetSeasonSponsorshipsUseCase';
export const GET_ALL_LEAGUES_WITH_CAPACITY_OUTPUT_PORT_TOKEN = 'GetAllLeaguesWithCapacityOutputPort_TOKEN'; 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_STANDINGS_OUTPUT_PORT_TOKEN = 'GetLeagueStandingsOutputPort_TOKEN';
export const GET_LEAGUE_PROTESTS_OUTPUT_PORT_TOKEN = 'GetLeagueProtestsOutputPort_TOKEN'; export const GET_LEAGUE_PROTESTS_OUTPUT_PORT_TOKEN = 'GetLeagueProtestsOutputPort_TOKEN';
export const GET_SEASON_SPONSORSHIPS_OUTPUT_PORT_TOKEN = 'GetSeasonSponsorshipsOutputPort_TOKEN'; export const GET_SEASON_SPONSORSHIPS_OUTPUT_PORT_TOKEN = 'GetSeasonSponsorshipsOutputPort_TOKEN';

View File

@@ -1,14 +1,124 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsNumber, IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer'; 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 { export class AllLeaguesWithCapacityAndScoringDTO {
@ApiProperty({ type: [LeagueSummaryDTO] }) @ApiProperty({ type: [LeagueWithCapacityAndScoringDTO] })
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => LeagueSummaryDTO) @Type(() => LeagueWithCapacityAndScoringDTO)
leagues!: LeagueSummaryDTO[]; leagues!: LeagueWithCapacityAndScoringDTO[];
@ApiProperty() @ApiProperty()
@IsNumber() @IsNumber()

View File

@@ -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<GetAllLeaguesWithCapacityAndScoringResult>
{
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 } : {};
}

View File

@@ -21,6 +21,76 @@ type RequestAvatarGenerationInput = RequestAvatarGenerationInputDTO;
type UploadMediaInput = UploadMediaInputDTO; type UploadMediaInput = UploadMediaInputDTO;
type UpdateAvatarInput = UpdateAvatarInputDTO; 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 `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="League logo">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${bg}"/>
<stop offset="100%" stop-color="hsl(${hue} 80% 46%)"/>
</linearGradient>
</defs>
<rect x="2" y="2" width="92" height="92" rx="18" fill="url(#g)" stroke="${border}" stroke-width="4"/>
<text x="48" y="56" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial" font-size="34" font-weight="800" text-anchor="middle" fill="white">${label}</text>
</svg>`;
}
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 `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="400" viewBox="0 0 1200 400" role="img" aria-label="League cover">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${bg1}"/>
<stop offset="100%" stop-color="${bg2}"/>
</linearGradient>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="rgba(255,255,255,0.07)" stroke-width="2"/>
</pattern>
</defs>
<rect width="1200" height="400" fill="url(#bg)"/>
<rect width="1200" height="400" fill="url(#grid)"/>
<circle cx="1020" cy="120" r="180" fill="rgba(255,255,255,0.06)"/>
<circle cx="1080" cy="170" r="120" fill="rgba(255,255,255,0.05)"/>
<text x="64" y="110" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial" font-size="40" font-weight="800" fill="rgba(255,255,255,0.92)">GridPilot League</text>
<text x="64" y="165" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial" font-size="22" font-weight="600" fill="rgba(255,255,255,0.75)">${title}</text>
</svg>`;
}
@ApiTags('media') @ApiTags('media')
@Controller('media') @Controller('media')
export class MediaController { 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<void> {
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<void> {
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() @Public()
@Get(':mediaId') @Get(':mediaId')
@ApiOperation({ summary: 'Get media by ID' }) @ApiOperation({ summary: 'Get media by ID' })

View File

@@ -15,6 +15,8 @@ import type { IPenaltyRepository } from '@core/racing/domain/repositories/IPenal
import type { IProtestRepository } from '@core/racing/domain/repositories/IProtestRepository'; import type { IProtestRepository } from '@core/racing/domain/repositories/IProtestRepository';
import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository'; import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository';
import type { ISeasonSponsorshipRepository } from '@core/racing/domain/repositories/ISeasonSponsorshipRepository'; 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 { ILeagueWalletRepository } from '@core/racing/domain/repositories/ILeagueWalletRepository';
import type { ITransactionRepository } from '@core/racing/domain/repositories/ITransactionRepository'; import type { ITransactionRepository } from '@core/racing/domain/repositories/ITransactionRepository';
import type { ISponsorRepository } from '@core/racing/domain/repositories/ISponsorRepository'; 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 { InMemoryProtestRepository } from '@adapters/racing/persistence/inmemory/InMemoryProtestRepository';
import { InMemorySeasonRepository } from '@adapters/racing/persistence/inmemory/InMemorySeasonRepository'; import { InMemorySeasonRepository } from '@adapters/racing/persistence/inmemory/InMemorySeasonRepository';
import { InMemorySeasonSponsorshipRepository } from '@adapters/racing/persistence/inmemory/InMemorySeasonSponsorshipRepository'; 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 { InMemoryLeagueWalletRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueWalletRepository';
import { InMemoryTransactionRepository } from '@adapters/racing/persistence/inmemory/InMemoryTransactionRepository'; import { InMemoryTransactionRepository } from '@adapters/racing/persistence/inmemory/InMemoryTransactionRepository';
import { InMemorySponsorRepository } from '@adapters/racing/persistence/inmemory/InMemorySponsorRepository'; 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 PROTEST_REPOSITORY_TOKEN = 'IProtestRepository';
export const SEASON_REPOSITORY_TOKEN = 'ISeasonRepository'; export const SEASON_REPOSITORY_TOKEN = 'ISeasonRepository';
export const SEASON_SPONSORSHIP_REPOSITORY_TOKEN = 'ISeasonSponsorshipRepository'; 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 LEAGUE_WALLET_REPOSITORY_TOKEN = 'ILeagueWalletRepository';
export const TRANSACTION_REPOSITORY_TOKEN = 'ITransactionRepository'; export const TRANSACTION_REPOSITORY_TOKEN = 'ITransactionRepository';
export const SPONSOR_REPOSITORY_TOKEN = 'ISponsorRepository'; export const SPONSOR_REPOSITORY_TOKEN = 'ISponsorRepository';
@@ -137,6 +143,16 @@ export const SPONSORSHIP_REQUEST_REPOSITORY_TOKEN = 'ISponsorshipRequestReposito
useFactory: (logger: Logger): ISeasonSponsorshipRepository => new InMemorySeasonSponsorshipRepository(logger), useFactory: (logger: Logger): ISeasonSponsorshipRepository => new InMemorySeasonSponsorshipRepository(logger),
inject: ['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, provide: LEAGUE_WALLET_REPOSITORY_TOKEN,
useFactory: (logger: Logger): ILeagueWalletRepository => new InMemoryLeagueWalletRepository(logger), useFactory: (logger: Logger): ILeagueWalletRepository => new InMemoryLeagueWalletRepository(logger),
@@ -177,6 +193,8 @@ export const SPONSORSHIP_REQUEST_REPOSITORY_TOKEN = 'ISponsorshipRequestReposito
PROTEST_REPOSITORY_TOKEN, PROTEST_REPOSITORY_TOKEN,
SEASON_REPOSITORY_TOKEN, SEASON_REPOSITORY_TOKEN,
SEASON_SPONSORSHIP_REPOSITORY_TOKEN, SEASON_SPONSORSHIP_REPOSITORY_TOKEN,
LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN,
GAME_REPOSITORY_TOKEN,
LEAGUE_WALLET_REPOSITORY_TOKEN, LEAGUE_WALLET_REPOSITORY_TOKEN,
TRANSACTION_REPOSITORY_TOKEN, TRANSACTION_REPOSITORY_TOKEN,
SPONSOR_REPOSITORY_TOKEN, SPONSOR_REPOSITORY_TOKEN,

View File

@@ -105,12 +105,12 @@ export default function LeagueCard({ league, onClick }: LeagueCardProps) {
<div className="relative h-full rounded-xl bg-iron-gray border border-charcoal-outline overflow-hidden transition-all duration-200 hover:border-primary-blue/50 hover:shadow-[0_0_30px_rgba(25,140,255,0.15)] hover:bg-iron-gray/80"> <div className="relative h-full rounded-xl bg-iron-gray border border-charcoal-outline overflow-hidden transition-all duration-200 hover:border-primary-blue/50 hover:shadow-[0_0_30px_rgba(25,140,255,0.15)] hover:bg-iron-gray/80">
{/* Cover Image */} {/* Cover Image */}
<div className="relative h-32 overflow-hidden"> <div className="relative h-32 overflow-hidden">
<Image <img
src={coverUrl} src={coverUrl}
alt={`${league.name} cover`} alt={`${league.name} cover`}
fill className="absolute inset-0 h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
className="object-cover transition-transform duration-300 group-hover:scale-105" loading="lazy"
sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw" decoding="async"
/> />
{/* Gradient Overlay */} {/* Gradient Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-iron-gray via-iron-gray/60 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-iron-gray via-iron-gray/60 to-transparent" />
@@ -141,12 +141,14 @@ export default function LeagueCard({ league, onClick }: LeagueCardProps) {
{/* Logo */} {/* Logo */}
<div className="absolute left-4 -bottom-6 z-10"> <div className="absolute left-4 -bottom-6 z-10">
<div className="w-12 h-12 rounded-lg overflow-hidden border-2 border-iron-gray bg-deep-graphite shadow-xl"> <div className="w-12 h-12 rounded-lg overflow-hidden border-2 border-iron-gray bg-deep-graphite shadow-xl">
<Image <img
src={logoUrl} src={logoUrl}
alt={`${league.name} logo`} alt={`${league.name} logo`}
width={48} width={48}
height={48} height={48}
className="w-full h-full object-cover" className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/> />
</div> </div>
</div> </div>

View File

@@ -37,12 +37,14 @@ export default function LeagueHeader({
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="h-16 w-16 rounded-xl overflow-hidden border-2 border-charcoal-outline bg-iron-gray shadow-lg"> <div className="h-16 w-16 rounded-xl overflow-hidden border-2 border-charcoal-outline bg-iron-gray shadow-lg">
<Image <img
src={logoUrl} src={logoUrl}
alt={`${leagueName} logo`} alt={`${leagueName} logo`}
width={64} width={64}
height={64} height={64}
className="w-full h-full object-cover" className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/> />
</div> </div>
<div> <div>

View File

@@ -11,6 +11,7 @@ import type { RaceDTO } from '../../types/generated/RaceDTO';
import type { GetLeagueAdminConfigOutputDTO } from '../../types/generated/GetLeagueAdminConfigOutputDTO'; import type { GetLeagueAdminConfigOutputDTO } from '../../types/generated/GetLeagueAdminConfigOutputDTO';
import type { LeagueScoringPresetDTO } from '../../types/generated/LeagueScoringPresetDTO'; import type { LeagueScoringPresetDTO } from '../../types/generated/LeagueScoringPresetDTO';
import type { LeagueSeasonSummaryDTO } from '../../types/generated/LeagueSeasonSummaryDTO'; import type { LeagueSeasonSummaryDTO } from '../../types/generated/LeagueSeasonSummaryDTO';
import type { AllLeaguesWithCapacityAndScoringDTO } from '../../types/AllLeaguesWithCapacityAndScoringDTO';
/** /**
* Leagues API Client * Leagues API Client
@@ -23,6 +24,11 @@ export class LeaguesApiClient extends BaseApiClient {
return this.get<AllLeaguesWithCapacityDTO>('/leagues/all-with-capacity'); return this.get<AllLeaguesWithCapacityDTO>('/leagues/all-with-capacity');
} }
/** Get all leagues with capacity + scoring summary (for leagues page filters) */
getAllWithCapacityAndScoring(): Promise<AllLeaguesWithCapacityAndScoringDTO> {
return this.get<AllLeaguesWithCapacityAndScoringDTO>('/leagues/all-with-capacity-and-scoring');
}
/** Get total number of leagues */ /** Get total number of leagues */
getTotal(): Promise<TotalLeaguesDTO> { getTotal(): Promise<TotalLeaguesDTO> {
return this.get<TotalLeaguesDTO>('/leagues/total-leagues'); return this.get<TotalLeaguesDTO>('/leagues/total-leagues');

View File

@@ -18,6 +18,7 @@ describe('LeagueService', () => {
beforeEach(() => { beforeEach(() => {
mockApiClient = { mockApiClient = {
getAllWithCapacity: vi.fn(), getAllWithCapacity: vi.fn(),
getAllWithCapacityAndScoring: vi.fn(),
getStandings: vi.fn(), getStandings: vi.fn(),
getTotal: vi.fn(), getTotal: vi.fn(),
getSchedule: vi.fn(), getSchedule: vi.fn(),
@@ -30,7 +31,7 @@ describe('LeagueService', () => {
}); });
describe('getAllLeagues', () => { 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 = { const mockDto = {
totalCount: 2, totalCount: 2,
leagues: [ leagues: [
@@ -39,11 +40,11 @@ describe('LeagueService', () => {
], ],
} as any; } as any;
mockApiClient.getAllWithCapacity.mockResolvedValue(mockDto); mockApiClient.getAllWithCapacityAndScoring.mockResolvedValue(mockDto);
const result = await service.getAllLeagues(); const result = await service.getAllLeagues();
expect(mockApiClient.getAllWithCapacity).toHaveBeenCalled(); expect(mockApiClient.getAllWithCapacityAndScoring).toHaveBeenCalled();
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
expect(result.map((l) => l.id)).toEqual(['league-1', 'league-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 () => { it('should handle empty leagues array', async () => {
const mockDto = { totalCount: 0, leagues: [] } as any; const mockDto = { totalCount: 0, leagues: [] } as any;
mockApiClient.getAllWithCapacity.mockResolvedValue(mockDto); mockApiClient.getAllWithCapacityAndScoring.mockResolvedValue(mockDto);
const result = await service.getAllLeagues(); const result = await service.getAllLeagues();
expect(result).toHaveLength(0); 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'); const error = new Error('API call failed');
mockApiClient.getAllWithCapacity.mockRejectedValue(error); mockApiClient.getAllWithCapacityAndScoring.mockRejectedValue(error);
await expect(service.getAllLeagues()).rejects.toThrow('API call failed'); await expect(service.getAllLeagues()).rejects.toThrow('API call failed');
}); });

View File

@@ -44,17 +44,20 @@ export class LeagueService {
* Get all leagues with view model transformation * Get all leagues with view model transformation
*/ */
async getAllLeagues(): Promise<LeagueSummaryViewModel[]> { async getAllLeagues(): Promise<LeagueSummaryViewModel[]> {
const dto = await this.apiClient.getAllWithCapacity(); const dto = await this.apiClient.getAllWithCapacityAndScoring();
return dto.leagues.map((league: LeagueWithCapacityDTO) => ({
return dto.leagues.map((league) => ({
id: league.id, id: league.id,
name: league.name, name: league.name,
description: (league as any).description ?? '', description: league.description,
ownerId: (league as any).ownerId ?? '', ownerId: league.ownerId,
createdAt: (league as any).createdAt ?? '', createdAt: league.createdAt,
maxDrivers: (league as any).settings?.maxDrivers ?? 0, maxDrivers: league.settings?.maxDrivers ?? 0,
usedDriverSlots: (league as any).usedSlots ?? 0, usedDriverSlots: league.usedSlots ?? 0,
structureSummary: 'TBD', structureSummary: league.scoring?.scoringPresetName ?? 'Custom rules',
timingSummary: 'TBD' 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 // For now, assume league data comes from getAllWithCapacity or a new endpoint
// Since API may not have detailed league, we'll mock or assume // Since API may not have detailed league, we'll mock or assume
// In real implementation, add getLeagueDetail to API // In real implementation, add getLeagueDetail to API
const allLeagues = await this.apiClient.getAllWithCapacity(); const allLeagues = await this.apiClient.getAllWithCapacityAndScoring();
const leagueDto = allLeagues.leagues.find((l: any) => l.id === leagueId); const leagueDto = allLeagues.leagues.find((l) => l.id === leagueId);
if (!leagueDto) return null; if (!leagueDto) return null;
// LeagueWithCapacityDTO already carries core fields; fall back to placeholder description/owner when not provided // LeagueWithCapacityDTO already carries core fields; fall back to placeholder description/owner when not provided
const league = { const league = {
id: leagueDto.id, id: leagueDto.id,
name: leagueDto.name, name: leagueDto.name,
description: (leagueDto as any).description ?? 'Description not available', description: leagueDto.description ?? 'Description not available',
ownerId: (leagueDto as any).ownerId ?? 'owner-id', ownerId: leagueDto.ownerId ?? 'owner-id',
}; };
// Get owner // Get owner
@@ -245,12 +248,12 @@ export class LeagueService {
try { try {
// Get league basic info // Get league basic info
const allLeagues = await this.apiClient.getAllWithCapacity(); const allLeagues = await this.apiClient.getAllWithCapacityAndScoring();
const league = allLeagues.leagues.find((l: any) => l.id === leagueId); const league = allLeagues.leagues.find((l) => l.id === leagueId);
if (!league) return null; if (!league) return null;
// Get owner // 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 // League scoring configuration is not exposed separately yet; use null to indicate "not configured" in the UI
const scoringConfig: LeagueScoringConfigDTO | null = null; const scoringConfig: LeagueScoringConfigDTO | null = null;

View File

@@ -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;
};

View File

@@ -20,6 +20,24 @@ const nextConfig = {
hostname: 'picsum.photos', 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: { typescript: {
ignoreBuildErrors: false, ignoreBuildErrors: false,

View File

@@ -28,6 +28,7 @@ services:
build: build:
context: . context: .
dockerfile: apps/api/Dockerfile.dev dockerfile: apps/api/Dockerfile.dev
working_dir: /app/apps/api
env_file: env_file:
- .env.development - .env.development
environment: environment:
@@ -40,7 +41,7 @@ services:
- ./:/app - ./:/app
- dev_node_modules:/app/node_modules - dev_node_modules:/app/node_modules
- dev_npm_cache:/root/.npm - dev_npm_cache:/root/.npm
command: ["sh", "-lc", "npm run start:dev --workspace=@gridpilot/api"] command: ["sh", "-lc", "npm run start:dev"]
depends_on: depends_on:
deps: deps:
condition: service_completed_successfully condition: service_completed_successfully