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

@@ -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',

View File

@@ -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<AllLeaguesWithCapacityAndScoringDTO> {
return this.leagueService.getAllLeaguesWithCapacityAndScoring();
}
@Public()
@Get('total-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 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: (

View File

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

View File

@@ -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<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> {
this.logger.debug('[LeagueService] Fetching total leagues count.');
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_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';

View File

@@ -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()

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 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')
@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<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()
@Get(':mediaId')
@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 { 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,