resolve todos in website and api

This commit is contained in:
2025-12-20 10:45:56 +01:00
parent 656ec62426
commit 7bbad511e2
62 changed files with 2036 additions and 611 deletions

View File

@@ -3,12 +3,18 @@ import { LeagueService } from './LeagueService';
// Import core interfaces
import type { Logger } from '@core/shared/application/Logger';
import type { ISeasonSponsorshipRepository } from '@core/racing/domain/repositories/ISeasonSponsorshipRepository';
import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
import type { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository';
// Import concrete in-memory implementations
import { InMemoryLeagueRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueRepository';
import { InMemoryLeagueMembershipRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueMembershipRepository';
import { InMemoryLeagueStandingsRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueStandingsRepository';
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 { InMemoryProtestRepository } from '@adapters/racing/persistence/inmemory/InMemoryProtestRepository';
@@ -25,6 +31,7 @@ import { GetAllLeaguesWithCapacityUseCase } from '@core/racing/application/use-c
import { GetLeagueStandingsUseCase } from '@core/league/application/use-cases/GetLeagueStandingsUseCase';
import { GetLeagueStandingsUseCaseImpl } from '@core/league/application/use-cases/GetLeagueStandingsUseCaseImpl';
import { GetLeagueFullConfigUseCase } from '@core/racing/application/use-cases/GetLeagueFullConfigUseCase';
import { GetSeasonSponsorshipsUseCase } from '@core/racing/application/use-cases/GetSeasonSponsorshipsUseCase';
import { CreateLeagueWithSeasonAndScoringUseCase } from '@core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase';
import { GetRaceProtestsUseCase } from '@core/racing/application/use-cases/GetRaceProtestsUseCase';
import { GetTotalLeaguesUseCase } from '@core/racing/application/use-cases/GetTotalLeaguesUseCase';
@@ -86,6 +93,11 @@ export const LeagueProviders: Provider[] = [
useFactory: (logger: Logger) => new InMemorySeasonRepository(logger), // Factory for InMemorySeasonRepository
inject: [LOGGER_TOKEN],
},
{
provide: 'ISeasonSponsorshipRepository',
useFactory: (logger: Logger) => new InMemorySeasonSponsorshipRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryLeagueScoringConfigRepository(logger), // Factory for InMemoryLeagueScoringConfigRepository
@@ -150,6 +162,23 @@ export const LeagueProviders: Provider[] = [
GetLeagueAdminPermissionsUseCase,
GetLeagueWalletUseCase,
WithdrawFromLeagueWalletUseCase,
{
provide: GetSeasonSponsorshipsUseCase,
useFactory: (
seasonSponsorshipRepo: ISeasonSponsorshipRepository,
seasonRepo: ISeasonRepository,
leagueRepo: ILeagueRepository,
leagueMembershipRepo: ILeagueMembershipRepository,
raceRepo: IRaceRepository,
) => new GetSeasonSponsorshipsUseCase(seasonSponsorshipRepo, seasonRepo, leagueRepo, leagueMembershipRepo, raceRepo),
inject: [
'ISeasonSponsorshipRepository',
SEASON_REPOSITORY_TOKEN,
LEAGUE_REPOSITORY_TOKEN,
LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
RACE_REPOSITORY_TOKEN,
],
},
{
provide: ListLeagueScoringPresetsUseCase,
useFactory: () => new ListLeagueScoringPresetsUseCase(listLeagueScoringPresets()),

View File

@@ -1,8 +1,12 @@
import { LeagueService } from './LeagueService';
import { GetAllLeaguesWithCapacityUseCase } from '@core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase';
import { GetLeagueStandingsUseCase } from '@core/racing/application/use-cases/GetLeagueStandingsUseCase';
import { GetLeagueStandingsUseCase } from '@core/league/application/use-cases/GetLeagueStandingsUseCase';
import { GetLeagueStatsUseCase } from '@core/racing/application/use-cases/GetLeagueStatsUseCase';
import { GetLeagueFullConfigUseCase } from '@core/racing/application/use-cases/GetLeagueFullConfigUseCase';
import { GetLeagueScoringConfigUseCase } from '@core/racing/application/use-cases/GetLeagueScoringConfigUseCase';
import { ListLeagueScoringPresetsUseCase } from '@core/racing/application/use-cases/ListLeagueScoringPresetsUseCase';
import { JoinLeagueUseCase } from '@core/racing/application/use-cases/JoinLeagueUseCase';
import { TransferLeagueOwnershipUseCase } from '@core/racing/application/use-cases/TransferLeagueOwnershipUseCase';
import { CreateLeagueWithSeasonAndScoringUseCase } from '@core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase';
import { GetRaceProtestsUseCase } from '@core/racing/application/use-cases/GetRaceProtestsUseCase';
import { GetTotalLeaguesUseCase } from '@core/racing/application/use-cases/GetTotalLeaguesUseCase';
@@ -13,6 +17,13 @@ import { RemoveLeagueMemberUseCase } from '@core/racing/application/use-cases/Re
import { UpdateLeagueMemberRoleUseCase } from '@core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase';
import { GetLeagueOwnerSummaryUseCase } from '@core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase';
import { GetLeagueProtestsUseCase } from '@core/racing/application/use-cases/GetLeagueProtestsUseCase';
import { GetLeagueSeasonsUseCase } from '@core/racing/application/use-cases/GetLeagueSeasonsUseCase';
import { GetLeagueMembershipsUseCase } from '@core/racing/application/use-cases/GetLeagueMembershipsUseCase';
import { GetLeagueScheduleUseCase } from '@core/racing/application/use-cases/GetLeagueScheduleUseCase';
import { GetLeagueAdminPermissionsUseCase } from '@core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase';
import { GetLeagueWalletUseCase } from '@core/racing/application/use-cases/GetLeagueWalletUseCase';
import { WithdrawFromLeagueWalletUseCase } from '@core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase';
import { GetSeasonSponsorshipsUseCase } from '@core/racing/application/use-cases/GetSeasonSponsorshipsUseCase';
import type { Logger } from '@core/shared/application/Logger';
import { Result } from '@core/shared/application/Result';
@@ -21,41 +32,58 @@ describe('LeagueService', () => {
let mockGetTotalLeaguesUseCase: jest.Mocked<GetTotalLeaguesUseCase>;
let mockGetLeagueJoinRequestsUseCase: jest.Mocked<GetLeagueJoinRequestsUseCase>;
let mockApproveLeagueJoinRequestUseCase: jest.Mocked<ApproveLeagueJoinRequestUseCase>;
let mockGetLeagueFullConfigUseCase: jest.Mocked<GetLeagueFullConfigUseCase>;
let mockGetLeagueOwnerSummaryUseCase: jest.Mocked<GetLeagueOwnerSummaryUseCase>;
let mockGetLeagueScheduleUseCase: jest.Mocked<GetLeagueScheduleUseCase>;
let mockGetSeasonSponsorshipsUseCase: jest.Mocked<GetSeasonSponsorshipsUseCase>;
let mockLogger: jest.Mocked<Logger>;
beforeEach(() => {
mockGetTotalLeaguesUseCase = {
execute: jest.fn(),
} as any;
mockGetLeagueJoinRequestsUseCase = {
execute: jest.fn(),
} as any;
mockApproveLeagueJoinRequestUseCase = {
execute: jest.fn(),
} as any;
const createUseCaseMock = <T extends { execute: unknown }>(): jest.Mocked<T> => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
execute: jest.fn() as any,
}) as jest.Mocked<T>;
mockGetTotalLeaguesUseCase = createUseCaseMock<GetTotalLeaguesUseCase>();
mockGetLeagueJoinRequestsUseCase = createUseCaseMock<GetLeagueJoinRequestsUseCase>();
mockApproveLeagueJoinRequestUseCase = createUseCaseMock<ApproveLeagueJoinRequestUseCase>();
mockGetLeagueFullConfigUseCase = createUseCaseMock<GetLeagueFullConfigUseCase>();
mockGetLeagueOwnerSummaryUseCase = createUseCaseMock<GetLeagueOwnerSummaryUseCase>();
mockGetLeagueScheduleUseCase = createUseCaseMock<GetLeagueScheduleUseCase>();
mockGetSeasonSponsorshipsUseCase = createUseCaseMock<GetSeasonSponsorshipsUseCase>();
mockLogger = {
debug: jest.fn(),
} as any;
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
} as unknown as jest.Mocked<Logger>;
service = new LeagueService(
{} as any, // mockGetAllLeaguesWithCapacityUseCase
{} as any, // mockGetLeagueStandingsUseCase
{} as any, // mockGetLeagueStatsUseCase
{} as any, // mockGetLeagueFullConfigUseCase
{} as any, // mockCreateLeagueWithSeasonAndScoringUseCase
{} as any, // mockGetRaceProtestsUseCase
{} as unknown as GetAllLeaguesWithCapacityUseCase,
{} as unknown as GetLeagueStandingsUseCase,
{} as unknown as GetLeagueStatsUseCase,
mockGetLeagueFullConfigUseCase,
{} as unknown as GetLeagueScoringConfigUseCase,
{} as unknown as ListLeagueScoringPresetsUseCase,
{} as unknown as JoinLeagueUseCase,
{} as unknown as TransferLeagueOwnershipUseCase,
{} as unknown as CreateLeagueWithSeasonAndScoringUseCase,
{} as unknown as GetRaceProtestsUseCase,
mockGetTotalLeaguesUseCase,
mockGetLeagueJoinRequestsUseCase,
mockApproveLeagueJoinRequestUseCase,
{} as any, // mockRejectLeagueJoinRequestUseCase
{} as any, // mockRemoveLeagueMemberUseCase
{} as any, // mockUpdateLeagueMemberRoleUseCase
{} as any, // mockGetLeagueOwnerSummaryUseCase
{} as any, // mockGetLeagueProtestsUseCase
{} as any, // mockGetLeagueSeasonsUseCase
{} as any, // mockGetLeagueMembershipsUseCase
{} as any, // mockGetLeagueScheduleUseCase
{} as any, // mockGetLeagueAdminPermissionsUseCase
{} as unknown as RejectLeagueJoinRequestUseCase,
{} as unknown as RemoveLeagueMemberUseCase,
{} as unknown as UpdateLeagueMemberRoleUseCase,
mockGetLeagueOwnerSummaryUseCase,
{} as unknown as GetLeagueProtestsUseCase,
{} as unknown as GetLeagueSeasonsUseCase,
{} as unknown as GetLeagueMembershipsUseCase,
mockGetLeagueScheduleUseCase,
{} as unknown as GetLeagueAdminPermissionsUseCase,
{} as unknown as GetLeagueWalletUseCase,
{} as unknown as WithdrawFromLeagueWalletUseCase,
mockGetSeasonSponsorshipsUseCase,
mockLogger,
);
});
@@ -70,7 +98,7 @@ describe('LeagueService', () => {
});
it('should get league join requests', async () => {
mockGetLeagueJoinRequestsUseCase.execute.mockImplementation(async (params, presenter) => {
mockGetLeagueJoinRequestsUseCase.execute.mockImplementation(async (_params, presenter) => {
presenter.present({
joinRequests: [{ id: 'req-1', leagueId: 'league-1', driverId: 'driver-1', requestedAt: new Date(), message: 'msg' }],
drivers: [{ id: 'driver-1', name: 'Driver 1' }],
@@ -79,18 +107,20 @@ describe('LeagueService', () => {
const result = await service.getLeagueJoinRequests('league-1');
expect(result).toEqual([{
id: 'req-1',
leagueId: 'league-1',
driverId: 'driver-1',
requestedAt: expect.any(Date),
message: 'msg',
driver: { id: 'driver-1', name: 'Driver 1' },
}]);
expect(result).toEqual([
{
id: 'req-1',
leagueId: 'league-1',
driverId: 'driver-1',
requestedAt: expect.any(Date),
message: 'msg',
driver: { id: 'driver-1', name: 'Driver 1' },
},
]);
});
it('should approve league join request', async () => {
mockApproveLeagueJoinRequestUseCase.execute.mockImplementation(async (params, presenter) => {
mockApproveLeagueJoinRequestUseCase.execute.mockImplementation(async (_params, presenter) => {
presenter.present({ success: true, message: 'Join request approved.' });
});
@@ -100,70 +130,200 @@ describe('LeagueService', () => {
});
it('should reject league join request', async () => {
const mockRejectUseCase = {
execute: jest.fn().mockImplementation(async (params, presenter) => {
presenter.present({ success: true, message: 'Join request rejected.' });
}),
} as any;
const mockRejectUseCase: jest.Mocked<RejectLeagueJoinRequestUseCase> = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
execute: jest.fn() as any,
} as unknown as jest.Mocked<RejectLeagueJoinRequestUseCase>;
service = new LeagueService(
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as unknown as GetAllLeaguesWithCapacityUseCase,
{} as unknown as GetLeagueStandingsUseCase,
{} as unknown as GetLeagueStatsUseCase,
mockGetLeagueFullConfigUseCase,
{} as unknown as GetLeagueScoringConfigUseCase,
{} as unknown as ListLeagueScoringPresetsUseCase,
{} as unknown as JoinLeagueUseCase,
{} as unknown as TransferLeagueOwnershipUseCase,
{} as unknown as CreateLeagueWithSeasonAndScoringUseCase,
{} as unknown as GetRaceProtestsUseCase,
mockGetTotalLeaguesUseCase,
mockGetLeagueJoinRequestsUseCase,
mockApproveLeagueJoinRequestUseCase,
mockRejectUseCase,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as unknown as RemoveLeagueMemberUseCase,
{} as unknown as UpdateLeagueMemberRoleUseCase,
mockGetLeagueOwnerSummaryUseCase,
{} as unknown as GetLeagueProtestsUseCase,
{} as unknown as GetLeagueSeasonsUseCase,
{} as unknown as GetLeagueMembershipsUseCase,
{} as unknown as GetLeagueScheduleUseCase,
{} as unknown as GetLeagueAdminPermissionsUseCase,
{} as unknown as GetLeagueWalletUseCase,
{} as unknown as WithdrawFromLeagueWalletUseCase,
mockLogger,
);
mockRejectUseCase.execute.mockImplementation(async (_params, presenter) => {
presenter.present({ success: true, message: 'Join request rejected.' });
});
const result = await service.rejectLeagueJoinRequest({ requestId: 'req-1', leagueId: 'league-1' });
expect(result).toEqual({ success: true, message: 'Join request rejected.' });
});
it('should remove league member', async () => {
const mockRemoveUseCase = {
execute: jest.fn().mockImplementation(async (params, presenter) => {
presenter.present({ success: true });
}),
} as any;
const mockRemoveUseCase: jest.Mocked<RemoveLeagueMemberUseCase> = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
execute: jest.fn() as any,
} as unknown as jest.Mocked<RemoveLeagueMemberUseCase>;
service = new LeagueService(
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as unknown as GetAllLeaguesWithCapacityUseCase,
{} as unknown as GetLeagueStandingsUseCase,
{} as unknown as GetLeagueStatsUseCase,
mockGetLeagueFullConfigUseCase,
{} as unknown as GetLeagueScoringConfigUseCase,
{} as unknown as ListLeagueScoringPresetsUseCase,
{} as unknown as JoinLeagueUseCase,
{} as unknown as TransferLeagueOwnershipUseCase,
{} as unknown as CreateLeagueWithSeasonAndScoringUseCase,
{} as unknown as GetRaceProtestsUseCase,
mockGetTotalLeaguesUseCase,
mockGetLeagueJoinRequestsUseCase,
mockApproveLeagueJoinRequestUseCase,
{} as unknown as RejectLeagueJoinRequestUseCase,
mockRemoveUseCase,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as unknown as UpdateLeagueMemberRoleUseCase,
mockGetLeagueOwnerSummaryUseCase,
{} as unknown as GetLeagueProtestsUseCase,
{} as unknown as GetLeagueSeasonsUseCase,
{} as unknown as GetLeagueMembershipsUseCase,
{} as unknown as GetLeagueScheduleUseCase,
{} as unknown as GetLeagueAdminPermissionsUseCase,
{} as unknown as GetLeagueWalletUseCase,
{} as unknown as WithdrawFromLeagueWalletUseCase,
mockLogger,
);
mockRemoveUseCase.execute.mockImplementation(async (_params, presenter) => {
presenter.present({ success: true });
});
const result = await service.removeLeagueMember({ leagueId: 'league-1', performerDriverId: 'performer-1', targetDriverId: 'driver-1' });
expect(result).toEqual({ success: true });
});
it('should aggregate league admin data via composite use case', async () => {
const fullConfig = {
league: {
id: 'league-1',
name: 'Test League',
description: 'Test',
ownerId: 'owner-1',
settings: { pointsSystem: 'custom' },
},
} as any;
mockGetLeagueFullConfigUseCase.execute.mockResolvedValue(Result.ok(fullConfig));
mockGetLeagueOwnerSummaryUseCase.execute.mockResolvedValue(Result.ok({ summary: null } as any));
const joinRequestsSpy = jest
.spyOn(service, 'getLeagueJoinRequests')
.mockResolvedValue({ joinRequests: [] } as any);
const protestsSpy = jest
.spyOn(service, 'getLeagueProtests')
.mockResolvedValue({ protests: [], racesById: {}, driversById: {} } as any);
const seasonsSpy = jest
.spyOn(service, 'getLeagueSeasons')
.mockResolvedValue([]);
const result = await service.getLeagueAdmin('league-1');
expect(mockGetLeagueFullConfigUseCase.execute).toHaveBeenCalledWith({ leagueId: 'league-1' });
expect(mockGetLeagueOwnerSummaryUseCase.execute).toHaveBeenCalledWith({ ownerId: 'owner-1' });
expect(joinRequestsSpy).toHaveBeenCalledWith('league-1');
expect(protestsSpy).toHaveBeenCalledWith({ leagueId: 'league-1' });
expect(seasonsSpy).toHaveBeenCalledWith({ leagueId: 'league-1' });
expect(result.config.form?.leagueId).toBe('league-1');
});
it('should get season sponsorships', async () => {
const sponsorship = {
id: 's-1',
leagueId: 'league-1',
leagueName: 'League 1',
seasonId: 'season-123',
seasonName: 'Season 1',
tier: 'gold',
status: 'active',
pricing: {
amount: 1000,
currency: 'USD',
},
platformFee: {
amount: 100,
currency: 'USD',
},
netAmount: {
amount: 900,
currency: 'USD',
},
metrics: {
drivers: 10,
races: 5,
completedRaces: 3,
impressions: 3000,
},
createdAt: new Date('2024-01-01T00:00:00.000Z'),
} as any;
mockGetSeasonSponsorshipsUseCase.execute.mockResolvedValue(
Result.ok({
seasonId: 'season-123',
sponsorships: [sponsorship],
}),
);
const result = await service.getSeasonSponsorships('season-123');
expect(mockGetSeasonSponsorshipsUseCase.execute).toHaveBeenCalledWith({ seasonId: 'season-123' });
expect(result.sponsorships).toHaveLength(1);
expect(result.sponsorships[0]).toMatchObject({
id: 's-1',
leagueId: 'league-1',
leagueName: 'League 1',
seasonId: 'season-123',
seasonName: 'Season 1',
tier: 'gold',
});
});
it('should get races for league', async () => {
const scheduledAt = new Date('2024-02-01T12:00:00.000Z');
mockGetLeagueScheduleUseCase.execute.mockResolvedValue(
Result.ok({
races: [
{
id: 'race-1',
name: 'Race 1',
scheduledAt,
},
],
}),
);
const result = await service.getRaces('league-123');
expect(mockGetLeagueScheduleUseCase.execute).toHaveBeenCalledWith({ leagueId: 'league-123' });
expect(result.races).toHaveLength(1);
expect(result.races[0]).toMatchObject({
id: 'race-1',
name: 'Race 1',
date: scheduledAt.toISOString(),
leagueName: undefined,
});
});
});

View File

@@ -60,6 +60,7 @@ import { TransferLeagueOwnershipUseCase } from '@core/racing/application/use-cas
import { UpdateLeagueMemberRoleUseCase } from '@core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase';
import { GetLeagueWalletUseCase } from '@core/racing/application/use-cases/GetLeagueWalletUseCase';
import { WithdrawFromLeagueWalletUseCase } from '@core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase';
import { GetSeasonSponsorshipsUseCase } from '@core/racing/application/use-cases/GetSeasonSponsorshipsUseCase';
// API Presenters
import { AllLeaguesWithCapacityPresenter } from './presenters/AllLeaguesWithCapacityPresenter';
@@ -70,7 +71,7 @@ import { mapApproveLeagueJoinRequestPortToDTO } from './presenters/ApproveLeague
import { GetLeagueAdminPermissionsPresenter } from './presenters/GetLeagueAdminPermissionsPresenter';
import { mapGetLeagueOwnerSummaryOutputPortToDTO } from './presenters/GetLeagueOwnerSummaryPresenter';
import { LeagueJoinRequestsPresenter } from './presenters/LeagueJoinRequestsPresenter';
import { mapGetLeagueScheduleOutputPortToDTO } from './presenters/LeagueSchedulePresenter';
import { mapGetLeagueScheduleOutputPortToDTO, mapGetLeagueScheduleOutputPortToRaceDTOs } from './presenters/LeagueSchedulePresenter';
import { LeagueStatsPresenter } from './presenters/LeagueStatsPresenter';
import { mapRejectLeagueJoinRequestOutputPortToDTO } from './presenters/RejectLeagueJoinRequestPresenter';
import { mapRemoveLeagueMemberOutputPortToDTO } from './presenters/RemoveLeagueMemberPresenter';
@@ -112,6 +113,7 @@ export class LeagueService {
private readonly getLeagueAdminPermissionsUseCase: GetLeagueAdminPermissionsUseCase,
private readonly getLeagueWalletUseCase: GetLeagueWalletUseCase,
private readonly withdrawFromLeagueWalletUseCase: WithdrawFromLeagueWalletUseCase,
private readonly getSeasonSponsorshipsUseCase: GetSeasonSponsorshipsUseCase,
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
) {}
@@ -263,11 +265,21 @@ export class LeagueService {
async getLeagueSchedule(leagueId: string): Promise<LeagueScheduleDTO> {
this.logger.debug('Getting league schedule', { leagueId });
const result = await this.getLeagueScheduleUseCase.execute({ leagueId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
const [scheduleResult, leagueConfigResult] = await Promise.all([
this.getLeagueScheduleUseCase.execute({ leagueId }),
this.getLeagueFullConfigUseCase.execute({ leagueId }),
]);
if (scheduleResult.isErr()) {
throw new Error(scheduleResult.unwrapErr().code);
}
return mapGetLeagueScheduleOutputPortToDTO(result.unwrap());
const leagueName = leagueConfigResult.isOk()
? leagueConfigResult.unwrap().league.name.toString()
: undefined;
return mapGetLeagueScheduleOutputPortToDTO(scheduleResult.unwrap(), leagueName);
}
async getLeagueStats(leagueId: string): Promise<LeagueStatsDTO> {
@@ -281,64 +293,49 @@ export class LeagueService {
return presenter.getViewModel()!;
}
async getLeagueAdmin(leagueId: string): Promise<LeagueAdminDTO> {
this.logger.debug('Getting league admin data', { leagueId });
// For now, we'll keep the orchestration in the service since it combines multiple use cases
// TODO: Create a composite use case that handles all the admin data fetching
const joinRequests = await this.getLeagueJoinRequests(leagueId);
const config = await this.getLeagueFullConfig({ leagueId });
const protests = await this.getLeagueProtests({ leagueId });
const seasons = await this.getLeagueSeasons({ leagueId });
private async getLeagueAdminComposite(leagueId: string): Promise<LeagueAdminDTO> {
this.logger.debug('Fetching composite league admin data', { leagueId });
// Get owner summary - we need the ownerId, so we use a simple approach for now
// In a full implementation, we'd have a use case that gets league basic info
const ownerSummary = config ? await this.getLeagueOwnerSummary({ ownerId: 'placeholder', leagueId }) : null;
const [fullConfigResult, joinRequests, protests, seasons] = await Promise.all([
this.getLeagueFullConfigUseCase.execute({ leagueId }),
this.getLeagueJoinRequests(leagueId),
this.getLeagueProtests({ leagueId }),
this.getLeagueSeasons({ leagueId }),
]);
// Convert config from view model to DTO format manually with proper types
const configForm = config ? {
leagueId: config.leagueId,
basics: {
name: config.basics.name,
description: config.basics.description,
visibility: config.basics.visibility as 'public' | 'private',
},
structure: {
mode: config.structure.mode as 'solo' | 'team',
},
championships: [], // TODO: Map championships from view model
scoring: {
type: 'standard' as const, // TODO: Map from view model
points: 25, // TODO: Map from view model
},
dropPolicy: {
strategy: config.dropPolicy.strategy as 'none' | 'worst_n',
n: config.dropPolicy.n ?? 0,
},
timings: {
raceDayOfWeek: 'sunday' as const, // TODO: Map from view model
raceTimeHour: 20, // TODO: Map from view model
raceTimeMinute: 0, // TODO: Map from view model
},
stewarding: {
decisionMode: config.stewarding.decisionMode === 'steward_vote' ? 'committee_vote' as const : 'single_steward' as const,
requireDefense: config.stewarding.requireDefense,
defenseTimeLimit: config.stewarding.defenseTimeLimit,
voteTimeLimit: config.stewarding.voteTimeLimit,
protestDeadlineHours: config.stewarding.protestDeadlineHours,
stewardingClosesHours: config.stewarding.stewardingClosesHours,
notifyAccusedOnProtest: config.stewarding.notifyAccusedOnProtest,
notifyOnVoteRequired: config.stewarding.notifyOnVoteRequired,
requiredVotes: config.stewarding.requiredVotes ?? 0,
},
} : null;
if (fullConfigResult.isErr()) {
throw new Error(fullConfigResult.unwrapErr().code);
}
return {
const fullConfig = fullConfigResult.unwrap();
const league = fullConfig.league;
const ownerSummaryResult = await this.getLeagueOwnerSummaryUseCase.execute({ ownerId: league.ownerId.toString() });
if (ownerSummaryResult.isErr()) {
throw new Error(ownerSummaryResult.unwrapErr().code);
}
const ownerSummary = mapGetLeagueOwnerSummaryOutputPortToDTO(ownerSummaryResult.unwrap());
const configPresenter = new LeagueConfigPresenter();
configPresenter.present(fullConfig);
const configForm = configPresenter.getViewModel();
const adminPresenter = new LeagueAdminPresenter();
adminPresenter.present({
joinRequests: joinRequests.joinRequests,
ownerSummary: ownerSummary?.summary || null,
config: { form: configForm },
ownerSummary,
config: configForm,
protests,
seasons,
};
});
return adminPresenter.getViewModel();
}
async getLeagueAdmin(leagueId: string): Promise<LeagueAdminDTO> {
this.logger.debug('Getting league admin data', { leagueId });
return this.getLeagueAdminComposite(leagueId);
}
async createLeague(input: CreateLeagueInputDTO): Promise<CreateLeagueViewModel> {
@@ -426,20 +423,30 @@ export class LeagueService {
async getSeasonSponsorships(seasonId: string): Promise<GetSeasonSponsorshipsOutputDTO> {
this.logger.debug('Getting season sponsorships', { seasonId });
// TODO: Implement actual logic to fetch season sponsorships
// For now, return empty array as placeholder
const result = await this.getSeasonSponsorshipsUseCase.execute({ seasonId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
const value = result.unwrap();
return {
sponsorships: [],
sponsorships: value?.sponsorships ?? [],
};
}
async getRaces(leagueId: string): Promise<GetLeagueRacesOutputDTO> {
this.logger.debug('Getting league races', { leagueId });
// TODO: Implement actual logic to fetch league races
// For now, return empty array as placeholder
const result = await this.getLeagueScheduleUseCase.execute({ leagueId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
const races = mapGetLeagueScheduleOutputPortToRaceDTOs(result.unwrap());
return {
races: [],
races,
};
}

View File

@@ -2,13 +2,23 @@ import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsDate, IsEnum } from 'class-validator';
import { Type } from 'class-transformer';
// TODO: protests are filed at race level but also managed on league level
/**
* ProtestDTO represents a protest that is filed against a specific race
* but is queried and managed in a league context.
*
* Both `leagueId` and `raceId` are exposed so that API consumers can
* clearly relate the protest back to the league admin view while still
* understanding which concrete race it belongs to.
*/
export class ProtestDTO {
@ApiProperty()
@IsString()
id: string;
@ApiProperty()
@IsString()
leagueId: string;
@ApiProperty()
@IsString()
raceId: string;

View File

@@ -1,28 +1,49 @@
import { GetLeagueProtestsOutputPort } from '@core/racing/application/ports/output/GetLeagueProtestsOutputPort';
import { GetLeagueProtestsOutputPort, type ProtestOutputPort } from '@core/racing/application/ports/output/GetLeagueProtestsOutputPort';
import { LeagueAdminProtestsDTO } from '../dtos/LeagueAdminProtestsDTO';
import { ProtestDTO } from '../dtos/ProtestDTO';
import { RaceDTO } from '../../race/dtos/RaceDTO';
import { DriverDTO } from '../../driver/dtos/DriverDTO';
export function mapGetLeagueProtestsOutputPortToDTO(output: GetLeagueProtestsOutputPort): LeagueAdminProtestsDTO {
const protests: ProtestDTO[] = output.protests.map(protest => ({
id: protest.id,
raceId: protest.raceId,
protestingDriverId: protest.protestingDriverId,
accusedDriverId: protest.accusedDriverId,
submittedAt: new Date(protest.filedAt),
description: protest.incident.description,
status: protest.status as 'pending' | 'accepted' | 'rejected', // TODO: map properly
}));
function mapProtestStatus(status: ProtestOutputPort['status']): ProtestDTO['status'] {
switch (status) {
case 'pending':
case 'awaiting_defense':
case 'under_review':
return 'pending';
case 'upheld':
return 'accepted';
case 'dismissed':
case 'withdrawn':
return 'rejected';
default:
return 'pending';
}
}
export function mapGetLeagueProtestsOutputPortToDTO(output: GetLeagueProtestsOutputPort, leagueName?: string): LeagueAdminProtestsDTO {
const protests: ProtestDTO[] = output.protests.map((protest) => {
const race = output.racesById[protest.raceId];
return {
id: protest.id,
leagueId: race?.leagueId,
raceId: protest.raceId,
protestingDriverId: protest.protestingDriverId,
accusedDriverId: protest.accusedDriverId,
submittedAt: new Date(protest.filedAt),
description: protest.incident.description,
status: mapProtestStatus(protest.status),
};
});
const racesById: { [raceId: string]: RaceDTO } = {};
for (const raceId in output.racesById) {
const race = output.racesById[raceId];
racesById[raceId] = {
id: race.id,
name: race.track, // assuming name is track
name: race.track,
date: race.scheduledAt,
leagueName: undefined, // TODO: get league name if needed
leagueName,
};
}

View File

@@ -0,0 +1,83 @@
import { LeagueConfigPresenter } from './LeagueConfigPresenter';
import type { LeagueFullConfigOutputPort } from '@core/racing/application/ports/output/LeagueFullConfigOutputPort';
describe('LeagueConfigPresenter', () => {
const createFullConfig = (overrides: Partial<LeagueFullConfigOutputPort> = {}): LeagueFullConfigOutputPort => {
const base: LeagueFullConfigOutputPort = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
league: {
id: 'league-1',
name: 'Test League',
description: 'Desc',
ownerId: 'owner-1',
settings: { pointsSystem: 'custom' },
createdAt: new Date(),
} as any,
activeSeason: {
id: 'season-1',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Season 1',
status: 'planned',
schedule: {
startDate: new Date('2025-01-05T19:00:00Z'),
timeOfDay: { hour: 20, minute: 0 } as any,
} as any,
dropPolicy: { strategy: 'bestNResults', n: 3 } as any,
stewardingConfig: {
decisionMode: 'steward_vote',
requiredVotes: 3,
requireDefense: true,
defenseTimeLimit: 24,
voteTimeLimit: 24,
protestDeadlineHours: 48,
stewardingClosesHours: 72,
notifyAccusedOnProtest: true,
notifyOnVoteRequired: true,
} as any,
} as any,
scoringConfig: {
id: 'scoring-1',
seasonId: 'season-1',
championships: [
{
id: 'champ-1',
name: 'Drivers',
type: 'driver' as any,
sessionTypes: ['race'] as any,
pointsTableBySessionType: {
race: {
getPointsForPosition: (pos: number) => (pos === 1 ? 25 : 0),
} as any,
},
dropScorePolicy: { strategy: 'bestNResults', count: 3 } as any,
},
],
} as any,
game: undefined,
...overrides,
};
return base;
};
it('maps league config into form model with scoring and timings', () => {
const presenter = new LeagueConfigPresenter();
const fullConfig = createFullConfig();
presenter.present(fullConfig);
const vm = presenter.getViewModel();
expect(vm).not.toBeNull();
expect(vm!.leagueId).toBe('league-1');
expect(vm!.basics.name).toBe('Test League');
expect(vm!.scoring.type).toBe('custom');
expect(vm!.scoring.points).toBe(25);
expect(vm!.championships.length).toBe(1);
expect(vm!.timings.raceTimeHour).toBe(20);
expect(vm!.timings.raceTimeMinute).toBe(0);
expect(vm!.dropPolicy.strategy).toBe('worst_n');
expect(vm!.dropPolicy.n).toBe(3);
expect(vm!.stewarding.decisionMode).toBe('committee_vote');
});
});

View File

@@ -10,45 +10,64 @@ export class LeagueConfigPresenter implements Presenter<LeagueFullConfigOutputPo
}
present(dto: LeagueFullConfigOutputPort) {
// Map from LeagueFullConfigOutputPort to LeagueConfigFormModelDTO
const league = dto.league;
const settings = league.settings;
const stewarding = settings.stewarding;
const stewarding = dto.activeSeason?.stewardingConfig;
const dropPolicy = dto.activeSeason?.dropPolicy;
const schedule = dto.activeSeason?.schedule;
const scoringConfig = dto.scoringConfig;
const visibility: 'public' | 'private' = 'public';
const championships = scoringConfig?.championships ?? [];
const firstChampionship = championships[0];
const firstSessionType = firstChampionship?.sessionTypes[0];
const firstPointsTable = firstSessionType
? firstChampionship.pointsTableBySessionType[firstSessionType]
: undefined;
const pointsForWin = firstPointsTable?.getPointsForPosition(1) ?? 0;
const raceDayOfWeek = schedule?.startDate
? schedule.startDate.toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase()
: 'sunday';
const raceTimeHour = schedule?.timeOfDay?.hour ?? 20;
const raceTimeMinute = schedule?.timeOfDay?.minute ?? 0;
this.result = {
leagueId: league.id,
basics: {
name: league.name,
description: league.description,
visibility: 'public', // TODO: Map visibility from league
visibility,
},
structure: {
mode: 'solo', // TODO: Map from league settings
mode: 'solo',
},
championships: [], // TODO: Map championships
championships,
scoring: {
type: 'standard', // TODO: Map scoring type
points: 25, // TODO: Map points
type: settings.pointsSystem,
points: pointsForWin,
},
dropPolicy: {
strategy: 'none', // TODO: Map
n: 0,
strategy: dropPolicy?.strategy === 'none' ? 'none' : 'worst_n',
n: dropPolicy?.n,
},
timings: {
raceDayOfWeek: 'sunday', // TODO: Map from timings
raceTimeHour: 20,
raceTimeMinute: 0,
raceDayOfWeek,
raceTimeHour,
raceTimeMinute,
},
stewarding: {
decisionMode: stewarding?.decisionMode === 'steward_vote' ? 'committee_vote' : 'single_steward',
requireDefense: stewarding?.requireDefense || false,
defenseTimeLimit: stewarding?.defenseTimeLimit || 48,
voteTimeLimit: stewarding?.voteTimeLimit || 72,
protestDeadlineHours: stewarding?.protestDeadlineHours || 48,
stewardingClosesHours: stewarding?.stewardingClosesHours || 168,
notifyAccusedOnProtest: stewarding?.notifyAccusedOnProtest || true,
notifyOnVoteRequired: stewarding?.notifyOnVoteRequired || true,
requiredVotes: stewarding?.requiredVotes || 0,
requireDefense: stewarding?.requireDefense ?? false,
defenseTimeLimit: stewarding?.defenseTimeLimit ?? 48,
voteTimeLimit: stewarding?.voteTimeLimit ?? 72,
protestDeadlineHours: stewarding?.protestDeadlineHours ?? 48,
stewardingClosesHours: stewarding?.stewardingClosesHours ?? 168,
notifyAccusedOnProtest: stewarding?.notifyAccusedOnProtest ?? true,
notifyOnVoteRequired: stewarding?.notifyOnVoteRequired ?? true,
requiredVotes: stewarding?.requiredVotes,
},
};
}

View File

@@ -2,13 +2,22 @@ import { GetLeagueScheduleOutputPort } from '@core/racing/application/ports/outp
import { LeagueScheduleDTO } from '../dtos/LeagueScheduleDTO';
import { RaceDTO } from '../../race/dtos/RaceDTO';
export function mapGetLeagueScheduleOutputPortToDTO(output: GetLeagueScheduleOutputPort): LeagueScheduleDTO {
export function mapGetLeagueScheduleOutputPortToDTO(output: GetLeagueScheduleOutputPort, leagueName?: string): LeagueScheduleDTO {
return {
races: output.races.map(race => ({
races: output.races.map<RaceDTO>(race => ({
id: race.id,
name: race.name,
date: race.scheduledAt.toISOString(),
leagueName: undefined, // TODO: get league name if needed
leagueName,
})),
};
}
export function mapGetLeagueScheduleOutputPortToRaceDTOs(output: GetLeagueScheduleOutputPort, leagueName?: string): RaceDTO[] {
return output.races.map<RaceDTO>(race => ({
id: race.id,
name: race.name,
date: race.scheduledAt.toISOString(),
leagueName,
}));
}