refactor core presenters

This commit is contained in:
2025-12-19 19:42:19 +01:00
parent 8116fe888f
commit 94fc538f44
228 changed files with 2817 additions and 3097 deletions

View File

@@ -49,10 +49,10 @@ describe('CompleteDriverOnboardingUseCase', () => {
expect(driverRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
id: 'user-1',
iracingId: 'user-1',
name: 'John Doe',
country: 'US',
bio: 'Test bio',
iracingId: expect.objectContaining({ value: 'user-1' }),
name: expect.objectContaining({ value: 'John Doe' }),
country: expect.objectContaining({ value: 'US' }),
bio: expect.objectContaining({ value: 'Test bio' }),
})
);
});
@@ -123,9 +123,9 @@ describe('CompleteDriverOnboardingUseCase', () => {
expect(driverRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
id: 'user-1',
iracingId: 'user-1',
name: 'John Doe',
country: 'US',
iracingId: expect.objectContaining({ value: 'user-1' }),
name: expect.objectContaining({ value: 'John Doe' }),
country: expect.objectContaining({ value: 'US' }),
bio: undefined,
})
);

View File

@@ -4,16 +4,17 @@ import { Driver } from '../../domain/entities/Driver';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { CompleteDriverOnboardingCommand } from '../dto/CompleteDriverOnboardingCommand';
import type { CompleteDriverOnboardingOutputPort } from '../ports/output/CompleteDriverOnboardingOutputPort';
/**
* Use Case for completing driver onboarding.
*/
export class CompleteDriverOnboardingUseCase
implements AsyncUseCase<CompleteDriverOnboardingCommand, { driverId: string }, string>
implements AsyncUseCase<CompleteDriverOnboardingCommand, CompleteDriverOnboardingOutputPort, string>
{
constructor(private readonly driverRepository: IDriverRepository) {}
async execute(command: CompleteDriverOnboardingCommand): Promise<Result<{ driverId: string }, ApplicationErrorCode<string>>> {
async execute(command: CompleteDriverOnboardingCommand): Promise<Result<CompleteDriverOnboardingOutputPort, ApplicationErrorCode<string>>> {
try {
// Check if driver already exists
const existing = await this.driverRepository.findById(command.userId);

View File

@@ -17,15 +17,15 @@ import { Driver } from '../../domain/entities/Driver';
import { Standing } from '../../domain/entities/Standing';
import type { FeedItem } from '@core/social/domain/types/FeedItem';
import type {
DashboardOverviewViewModel,
DashboardDriverSummaryViewModel,
DashboardRaceSummaryViewModel,
DashboardRecentResultViewModel,
DashboardLeagueStandingSummaryViewModel,
DashboardFeedItemSummaryViewModel,
DashboardFeedSummaryViewModel,
DashboardFriendSummaryViewModel,
} from '../presenters/IDashboardOverviewPresenter';
DashboardOverviewOutputPort,
DashboardDriverSummaryOutputPort,
DashboardRaceSummaryOutputPort,
DashboardRecentResultOutputPort,
DashboardLeagueStandingSummaryOutputPort,
DashboardFeedItemSummaryOutputPort,
DashboardFeedSummaryOutputPort,
DashboardFriendSummaryOutputPort,
} from '../ports/output/DashboardOverviewOutputPort';
interface DashboardOverviewParams {
driverId: string;
@@ -55,7 +55,7 @@ export class DashboardOverviewUseCase {
private readonly getDriverStats: (driverId: string) => DashboardDriverStatsAdapter | null,
) {}
async execute(params: DashboardOverviewParams): Promise<Result<DashboardOverviewViewModel>> {
async execute(params: DashboardOverviewParams): Promise<Result<DashboardOverviewOutputPort>> {
const { driverId } = params;
const [driver, allLeagues, allRaces, allResults, feedItems, friends] = await Promise.all([
@@ -71,7 +71,7 @@ export class DashboardOverviewUseCase {
const driverStats = this.getDriverStats(driverId);
const currentDriver: DashboardDriverSummaryViewModel | null = driver
const currentDriver: DashboardDriverSummaryOutputPort | null = driver
? {
id: driver.id,
name: driver.name,
@@ -101,10 +101,10 @@ export class DashboardOverviewUseCase {
const { myUpcomingRaces, otherUpcomingRaces } =
await this.partitionUpcomingRacesByRegistration(upcomingRacesInDriverLeagues, driverId, leagueMap);
const nextRace: DashboardRaceSummaryViewModel | null =
const nextRace: DashboardRaceSummaryOutputPort | null =
myUpcomingRaces.length > 0 ? myUpcomingRaces[0]! : null;
const upcomingRacesSummaries: DashboardRaceSummaryViewModel[] = [
const upcomingRacesSummaries: DashboardRaceSummaryOutputPort[] = [
...myUpcomingRaces,
...otherUpcomingRaces,
].slice().sort(
@@ -128,7 +128,7 @@ export class DashboardOverviewUseCase {
const friendsSummary = await this.buildFriendsSummary(friends);
const viewModel: DashboardOverviewViewModel = {
const viewModel: DashboardOverviewOutputPort = {
currentDriver,
myUpcomingRaces,
otherUpcomingRaces,
@@ -162,11 +162,11 @@ export class DashboardOverviewUseCase {
driverId: string,
leagueMap: Map<string, string>,
): Promise<{
myUpcomingRaces: DashboardRaceSummaryViewModel[];
otherUpcomingRaces: DashboardRaceSummaryViewModel[];
myUpcomingRaces: DashboardRaceSummaryOutputPort[];
otherUpcomingRaces: DashboardRaceSummaryOutputPort[];
}> {
const myUpcomingRaces: DashboardRaceSummaryViewModel[] = [];
const otherUpcomingRaces: DashboardRaceSummaryViewModel[] = [];
const myUpcomingRaces: DashboardRaceSummaryOutputPort[] = [];
const otherUpcomingRaces: DashboardRaceSummaryOutputPort[] = [];
for (const race of upcomingRaces) {
const isRegistered = await this.raceRegistrationRepository.isRegistered(race.id, driverId);
@@ -186,7 +186,7 @@ export class DashboardOverviewUseCase {
race: Race,
leagueMap: Map<string, string>,
isMyLeague: boolean,
): DashboardRaceSummaryViewModel {
): DashboardRaceSummaryOutputPort {
return {
id: race.id,
leagueId: race.leagueId,
@@ -204,7 +204,7 @@ export class DashboardOverviewUseCase {
allRaces: Race[],
allLeagues: League[],
driverId: string,
): DashboardRecentResultViewModel[] {
): DashboardRecentResultOutputPort[] {
const raceById = new Map(allRaces.map(race => [race.id, race]));
const leagueById = new Map(allLeagues.map(league => [league.id, league]));
@@ -219,7 +219,7 @@ export class DashboardOverviewUseCase {
const finishedAt = race.scheduledAt.toISOString();
const item: DashboardRecentResultViewModel = {
const item: DashboardRecentResultOutputPort = {
raceId: race.id,
raceName: race.track,
leagueId: race.leagueId,
@@ -231,7 +231,7 @@ export class DashboardOverviewUseCase {
return item;
})
.filter((item): item is DashboardRecentResultViewModel => !!item)
.filter((item): item is DashboardRecentResultOutputPort => !!item)
.sort(
(a, b) =>
new Date(b.finishedAt).getTime() - new Date(a.finishedAt).getTime(),
@@ -245,8 +245,8 @@ export class DashboardOverviewUseCase {
private async buildLeagueStandingsSummaries(
driverLeagues: League[],
driverId: string,
): Promise<DashboardLeagueStandingSummaryViewModel[]> {
const summaries: DashboardLeagueStandingSummaryViewModel[] = [];
): Promise<DashboardLeagueStandingSummaryOutputPort[]> {
const summaries: DashboardLeagueStandingSummaryOutputPort[] = [];
for (const league of driverLeagues.slice(0, 3)) {
const standings = await this.standingRepository.findByLeagueId(league.id);
@@ -267,8 +267,8 @@ export class DashboardOverviewUseCase {
}
private computeActiveLeaguesCount(
upcomingRaces: DashboardRaceSummaryViewModel[],
leagueStandingsSummaries: DashboardLeagueStandingSummaryViewModel[],
upcomingRaces: DashboardRaceSummaryOutputPort[],
leagueStandingsSummaries: DashboardLeagueStandingSummaryOutputPort[],
): number {
const activeLeagueIds = new Set<string>();
@@ -283,8 +283,8 @@ export class DashboardOverviewUseCase {
return activeLeagueIds.size;
}
private buildFeedSummary(feedItems: FeedItem[]): DashboardFeedSummaryViewModel {
const items: DashboardFeedItemSummaryViewModel[] = feedItems.map(item => ({
private buildFeedSummary(feedItems: FeedItem[]): DashboardFeedSummaryOutputPort {
const items: DashboardFeedItemSummaryOutputPort[] = feedItems.map(item => ({
id: item.id,
type: item.type,
headline: item.headline,
@@ -303,8 +303,8 @@ export class DashboardOverviewUseCase {
};
}
private async buildFriendsSummary(friends: Driver[]): Promise<DashboardFriendSummaryViewModel[]> {
const friendSummaries: DashboardFriendSummaryViewModel[] = [];
private async buildFriendsSummary(friends: Driver[]): Promise<DashboardFriendSummaryOutputPort[]> {
const friendSummaries: DashboardFriendSummaryOutputPort[] = [];
for (const friend of friends) {
const avatarResult = await this.getDriverAvatar({ driverId: friend.id });

View File

@@ -54,15 +54,17 @@ describe('GetAllLeaguesWithCapacityAndScoringUseCase', () => {
const result = await useCase.execute();
expect(result.isOk()).toBe(true);
expect(result.value).toEqual([
{
league,
usedDriverSlots: 2,
season,
scoringConfig,
game,
preset,
},
]);
expect(result.value).toEqual({
leagues: [
{
league,
usedDriverSlots: 2,
season,
scoringConfig,
game,
preset,
},
],
});
});
});

View File

@@ -4,7 +4,7 @@ import type { ISeasonRepository } from '../../domain/repositories/ISeasonReposit
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider';
import type { LeagueEnrichedData } from '../presenters/IAllLeaguesWithCapacityAndScoringPresenter';
import type { LeagueEnrichedData, AllLeaguesWithCapacityAndScoringOutputPort } from '../ports/output/AllLeaguesWithCapacityAndScoringOutputPort';
import { Result } from '@core/shared/application/Result';
/**
@@ -22,7 +22,7 @@ export class GetAllLeaguesWithCapacityAndScoringUseCase
private readonly presetProvider: LeagueScoringPresetProvider,
) {}
async execute(): Promise<Result<LeagueEnrichedData[]>> {
async execute(): Promise<Result<AllLeaguesWithCapacityAndScoringOutputPort>> {
const leagues = await this.leagueRepository.findAll();
const enrichedLeagues: LeagueEnrichedData[] = [];
@@ -72,7 +72,7 @@ export class GetAllLeaguesWithCapacityAndScoringUseCase
...(preset ? { preset } : {}),
});
}
return Result.ok(enrichedLeagues);
return Result.ok({ leagues: enrichedLeagues });
}
}

View File

@@ -1,6 +1,6 @@
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { AllLeaguesWithCapacityResultDTO } from '../presenters/IAllLeaguesWithCapacityPresenter';
import type { AllLeaguesWithCapacityOutputPort } from '../ports/output/AllLeaguesWithCapacityOutputPort';
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -10,17 +10,17 @@ import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorC
* Orchestrates domain logic and returns result.
*/
export class GetAllLeaguesWithCapacityUseCase
implements AsyncUseCase<void, AllLeaguesWithCapacityResultDTO, string>
implements AsyncUseCase<void, AllLeaguesWithCapacityOutputPort, string>
{
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
) {}
async execute(): Promise<Result<AllLeaguesWithCapacityResultDTO, ApplicationErrorCode<string>>> {
async execute(): Promise<Result<AllLeaguesWithCapacityOutputPort, ApplicationErrorCode<string>>> {
const leagues = await this.leagueRepository.findAll();
const memberCounts = new Map<string, number>();
const memberCounts: Record<string, number> = {};
for (const league of leagues) {
const members = await this.leagueMembershipRepository.getLeagueMembers(league.id);
@@ -34,14 +34,14 @@ export class GetAllLeaguesWithCapacityUseCase
m.role === 'member'),
).length;
memberCounts.set(league.id, usedSlots);
memberCounts[league.id] = usedSlots;
}
const dto: AllLeaguesWithCapacityResultDTO = {
const output: AllLeaguesWithCapacityOutputPort = {
leagues,
memberCounts,
};
return Result.ok(dto);
return Result.ok(output);
}
}

View File

@@ -1,18 +1,12 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { Logger } from '@core/shared/application';
import type {
AllRacesPageResultDTO,
AllRacesPageViewModel,
AllRacesListItemViewModel,
AllRacesFilterOptionsViewModel,
} from '../presenters/IAllRacesPagePresenter';
import type { AsyncUseCase } from '@core/shared/application';
import type { Logger , AsyncUseCase } from '@core/shared/application';
import type { AllRacesPageOutputPort, AllRacesListItem, AllRacesFilterOptions } from '../ports/output/AllRacesPageOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export class GetAllRacesPageDataUseCase
implements AsyncUseCase<void, AllRacesPageResultDTO, 'REPOSITORY_ERROR'> {
implements AsyncUseCase<void, AllRacesPageOutputPort, 'REPOSITORY_ERROR'> {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
@@ -30,7 +24,7 @@ export class GetAllRacesPageDataUseCase
const leagueMap = new Map(allLeagues.map((league) => [league.id.toString(), league.name.toString()]));
const races: AllRacesListItemViewModel[] = allRaces
const races: AllRacesListItem[] = allRaces
.slice()
.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime())
.map((race) => ({
@@ -49,7 +43,7 @@ export class GetAllRacesPageDataUseCase
uniqueLeagues.set(league.id.toString(), { id: league.id.toString(), name: league.name.toString() });
}
const filters: AllRacesFilterOptionsViewModel = {
const filters: AllRacesFilterOptions = {
statuses: [
{ value: 'all', label: 'All Statuses' },
{ value: 'scheduled', label: 'Scheduled' },
@@ -60,7 +54,7 @@ export class GetAllRacesPageDataUseCase
leagues: Array.from(uniqueLeagues.values()),
};
const viewModel: AllRacesPageViewModel = {
const viewModel: AllRacesPageOutputPort = {
races,
filters,
};

View File

@@ -1,38 +1,40 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { GetAllRacesResultDTO } from '../presenters/IGetAllRacesPresenter';
import type { GetAllRacesOutputPort } from '../ports/output/GetAllRacesOutputPort';
import type { AsyncUseCase, Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export class GetAllRacesUseCase implements AsyncUseCase<void, GetAllRacesResultDTO, 'REPOSITORY_ERROR'> {
export class GetAllRacesUseCase implements AsyncUseCase<void, GetAllRacesOutputPort, 'REPOSITORY_ERROR'> {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly logger: Logger,
) {}
async execute(): Promise<Result<GetAllRacesResultDTO, ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>>> {
async execute(): Promise<Result<GetAllRacesOutputPort, ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>>> {
this.logger.debug('Executing GetAllRacesUseCase');
try {
const races = await this.raceRepository.findAll();
const leagues = await this.leagueRepository.findAll();
const leagueMap = new Map(leagues.map(league => [league.id, league.name]));
const raceViewModels = races.map(race => ({
id: race.id,
name: `${race.track} - ${race.car}`,
date: race.scheduledAt.toISOString(),
leagueName: leagueMap.get(race.leagueId) || 'Unknown League',
}));
const dto: GetAllRacesResultDTO = {
races: raceViewModels,
const output: GetAllRacesOutputPort = {
races: races.map(race => ({
id: race.id,
leagueId: race.leagueId,
track: race.track,
car: race.car,
status: race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
scheduledAt: race.scheduledAt.toISOString(),
strengthOfField: race.strengthOfField || null,
leagueName: (leagueMap.get(race.leagueId) || 'Unknown League').toString(),
})),
totalCount: races.length,
};
this.logger.debug('Successfully retrieved all races.');
return Result.ok(dto);
return Result.ok(output);
} catch (error) {
this.logger.error('Error executing GetAllRacesUseCase', error instanceof Error ? error : new Error(String(error)));
return Result.err({

View File

@@ -1,6 +1,6 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { AllTeamsResultDTO } from '../presenters/IAllTeamsPresenter';
import type { GetAllTeamsOutputPort } from '../ports/output/GetAllTeamsOutputPort';
import type { AsyncUseCase, Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -8,7 +8,7 @@ import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorC
/**
* Use Case for retrieving all teams.
*/
export class GetAllTeamsUseCase implements AsyncUseCase<void, AllTeamsResultDTO, 'REPOSITORY_ERROR'> {
export class GetAllTeamsUseCase implements AsyncUseCase<void, GetAllTeamsOutputPort, 'REPOSITORY_ERROR'> {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly teamMembershipRepository: ITeamMembershipRepository,
@@ -37,8 +37,9 @@ export class GetAllTeamsUseCase implements AsyncUseCase<void, AllTeamsResultDTO,
}),
);
const dto: AllTeamsResultDTO = {
const dto: GetAllTeamsOutputPort = {
teams: enrichedTeams,
totalCount: enrichedTeams.length,
};
this.logger.debug('Successfully retrieved all teams.');

View File

@@ -1,6 +1,6 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { DriverTeamResultDTO } from '../presenters/IDriverTeamPresenter';
import type { DriverTeamOutputPort } from '../ports/output/DriverTeamOutputPort';
import type { AsyncUseCase, Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -10,7 +10,7 @@ import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorC
* Orchestrates domain logic and returns result.
*/
export class GetDriverTeamUseCase
implements AsyncUseCase<{ driverId: string }, Result<DriverTeamResultDTO, ApplicationErrorCode<'MEMBERSHIP_NOT_FOUND' | 'TEAM_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }>>>
implements AsyncUseCase<{ driverId: string }, Result<DriverTeamOutputPort, ApplicationErrorCode<'MEMBERSHIP_NOT_FOUND' | 'TEAM_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }>>>
{
constructor(
private readonly teamRepository: ITeamRepository,
@@ -18,7 +18,7 @@ export class GetDriverTeamUseCase
private readonly logger: Logger,
) {}
async execute(input: { driverId: string }): Promise<Result<DriverTeamResultDTO, ApplicationErrorCode<'MEMBERSHIP_NOT_FOUND' | 'TEAM_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }>>> {
async execute(input: { driverId: string }): Promise<Result<DriverTeamOutputPort, ApplicationErrorCode<'MEMBERSHIP_NOT_FOUND' | 'TEAM_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }>>> {
this.logger.debug(`Executing GetDriverTeamUseCase for driverId: ${input.driverId}`);
try {
const membership = await this.membershipRepository.getActiveMembershipForDriver(input.driverId);
@@ -35,14 +35,22 @@ export class GetDriverTeamUseCase
}
this.logger.debug(`Found team for teamId: ${team.id}, name: ${team.name}`);
const dto: DriverTeamResultDTO = {
team,
membership,
const output: DriverTeamOutputPort = {
driverId: input.driverId,
team: {
id: team.id,
name: team.name.value,
tag: team.tag.value,
description: team.description.value,
ownerId: team.ownerId.value,
leagues: team.leagues.map(l => l.value),
createdAt: team.createdAt.value,
},
membership,
};
this.logger.info(`Successfully retrieved driver team for driverId: ${input.driverId}`);
return Result.ok(dto);
return Result.ok(output);
} catch (error) {
this.logger.error('Error executing GetDriverTeamUseCase', error instanceof Error ? error : new Error(String(error)));
return Result.err({ code: 'REPOSITORY_ERROR', details: { message: error instanceof Error ? error.message : 'Unknown error occurred' } });

View File

@@ -47,11 +47,14 @@ describe('GetDriversLeaderboardUseCase', () => {
mockLogger,
);
const driver1 = { id: 'driver1', name: 'Driver One' };
const driver2 = { id: 'driver2', name: 'Driver Two' };
const rankings = { driver1: 1, driver2: 2 };
const stats1 = { wins: 5, losses: 2 };
const stats2 = { wins: 3, losses: 1 };
const driver1 = { id: 'driver1', name: { value: 'Driver One' }, country: { value: 'US' } };
const driver2 = { id: 'driver2', name: { value: 'Driver Two' }, country: { value: 'US' } };
const rankings = [
{ driverId: 'driver1', rating: 2500, overallRank: 1 },
{ driverId: 'driver2', rating: 2400, overallRank: 2 },
];
const stats1 = { totalRaces: 10, wins: 5, podiums: 7 };
const stats2 = { totalRaces: 8, wins: 3, podiums: 4 };
mockDriverFindAll.mockResolvedValue([driver1, driver2]);
mockRankingGetAllDriverRankings.mockReturnValue(rankings);
@@ -70,10 +73,37 @@ describe('GetDriversLeaderboardUseCase', () => {
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({
drivers: [driver1, driver2],
rankings,
stats: { driver1: stats1, driver2: stats2 },
avatarUrls: { driver1: 'avatar-driver1', driver2: 'avatar-driver2' },
drivers: [
{
id: 'driver1',
name: 'Driver One',
rating: 2500,
skillLevel: 'Pro',
nationality: 'US',
racesCompleted: 10,
wins: 5,
podiums: 7,
isActive: true,
rank: 1,
avatarUrl: 'avatar-driver1',
},
{
id: 'driver2',
name: 'Driver Two',
rating: 2400,
skillLevel: 'Pro',
nationality: 'US',
racesCompleted: 8,
wins: 3,
podiums: 4,
isActive: true,
rank: 2,
avatarUrl: 'avatar-driver2',
},
],
totalRaces: 18,
totalWins: 8,
activeCount: 2,
});
});
@@ -87,16 +117,16 @@ describe('GetDriversLeaderboardUseCase', () => {
);
mockDriverFindAll.mockResolvedValue([]);
mockRankingGetAllDriverRankings.mockReturnValue({});
mockRankingGetAllDriverRankings.mockReturnValue([]);
const result = await useCase.execute();
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({
drivers: [],
rankings: {},
stats: {},
avatarUrls: {},
totalRaces: 0,
totalWins: 0,
activeCount: 0,
});
});
@@ -109,8 +139,8 @@ describe('GetDriversLeaderboardUseCase', () => {
mockLogger,
);
const driver1 = { id: 'driver1', name: 'Driver One' };
const rankings = { driver1: 1 };
const driver1 = { id: 'driver1', name: { value: 'Driver One' }, country: { value: 'US' } };
const rankings = [{ driverId: 'driver1', rating: 2500, overallRank: 1 }];
mockDriverFindAll.mockResolvedValue([driver1]);
mockRankingGetAllDriverRankings.mockReturnValue(rankings);
@@ -121,10 +151,24 @@ describe('GetDriversLeaderboardUseCase', () => {
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({
drivers: [driver1],
rankings,
stats: {},
avatarUrls: { driver1: 'avatar-driver1' },
drivers: [
{
id: 'driver1',
name: 'Driver One',
rating: 2500,
skillLevel: 'Pro',
nationality: 'US',
racesCompleted: 0,
wins: 0,
podiums: 0,
isActive: true,
rank: 1,
avatarUrl: 'avatar-driver1',
},
],
totalRaces: 0,
totalWins: 0,
activeCount: 1,
});
});

View File

@@ -3,7 +3,8 @@ import type { IRankingService } from '../../domain/services/IRankingService';
import type { IDriverStatsService } from '../../domain/services/IDriverStatsService';
import type { GetDriverAvatarInputPort } from '../ports/input/GetDriverAvatarInputPort';
import type { GetDriverAvatarOutputPort } from '../ports/output/GetDriverAvatarOutputPort';
import type { DriversLeaderboardResultDTO } from '../presenters/IDriversLeaderboardPresenter';
import type { DriversLeaderboardOutputPort, DriverLeaderboardItemOutputPort } from '../ports/output/DriversLeaderboardOutputPort';
import type { SkillLevel } from '../../domain/services/SkillLevelService';
import type { AsyncUseCase, Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -13,7 +14,7 @@ import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorC
* Orchestrates domain logic and returns result.
*/
export class GetDriversLeaderboardUseCase
implements AsyncUseCase<void, DriversLeaderboardResultDTO, 'REPOSITORY_ERROR'>
implements AsyncUseCase<void, DriversLeaderboardOutputPort, 'REPOSITORY_ERROR'>
{
constructor(
private readonly driverRepository: IDriverRepository,
@@ -23,34 +24,52 @@ export class GetDriversLeaderboardUseCase
private readonly logger: Logger,
) {}
async execute(): Promise<Result<DriversLeaderboardResultDTO, ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>>> {
async execute(): Promise<Result<DriversLeaderboardOutputPort, ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>>> {
this.logger.debug('Executing GetDriversLeaderboardUseCase');
try {
const drivers = await this.driverRepository.findAll();
const rankings = this.rankingService.getAllDriverRankings();
const stats: DriversLeaderboardResultDTO['stats'] = {};
const avatarUrls: DriversLeaderboardResultDTO['avatarUrls'] = {};
const avatarUrls: Record<string, string> = {};
for (const driver of drivers) {
const driverStats = this.driverStatsService.getDriverStats(driver.id);
if (driverStats) {
stats[driver.id] = driverStats;
}
const avatarResult = await this.getDriverAvatar({ driverId: driver.id });
avatarUrls[driver.id] = avatarResult.avatarUrl;
}
const dto: DriversLeaderboardResultDTO = {
drivers,
rankings,
stats,
avatarUrls,
const driverItems: DriverLeaderboardItemOutputPort[] = drivers.map(driver => {
const ranking = rankings.find(r => r.driverId === driver.id);
const stats = this.driverStatsService.getDriverStats(driver.id);
return {
id: driver.id,
name: driver.name.value,
rating: ranking?.rating ?? 0,
skillLevel: 'Pro' as SkillLevel, // TODO: map from domain
nationality: driver.country.value,
racesCompleted: stats?.totalRaces ?? 0,
wins: stats?.wins ?? 0,
podiums: stats?.podiums ?? 0,
isActive: true, // TODO: determine from domain
rank: ranking?.overallRank ?? 0,
avatarUrl: avatarUrls[driver.id],
};
});
// Calculate totals
const totalRaces = driverItems.reduce((sum, d) => sum + d.racesCompleted, 0);
const totalWins = driverItems.reduce((sum, d) => sum + d.wins, 0);
const activeCount = driverItems.filter(d => d.isActive).length;
const result: DriversLeaderboardOutputPort = {
drivers: driverItems.sort((a, b) => b.rating - a.rating),
totalRaces,
totalWins,
activeCount,
};
this.logger.debug('Successfully retrieved drivers leaderboard.');
return Result.ok(dto);
return Result.ok(result);
} catch (error) {
this.logger.error('Error executing GetDriversLeaderboardUseCase', error instanceof Error ? error : new Error(String(error)));
return Result.err({

View File

@@ -4,6 +4,8 @@ import type { IStandingRepository } from '../../domain/repositories/IStandingRep
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { DriverRatingPort } from '../ports/DriverRatingPort';
describe('GetLeagueDriverSeasonStatsUseCase', () => {
@@ -12,12 +14,16 @@ describe('GetLeagueDriverSeasonStatsUseCase', () => {
const mockPenaltyFindByRaceId = vi.fn();
const mockRaceFindByLeagueId = vi.fn();
const mockDriverRatingGetRating = vi.fn();
const mockDriverFindById = vi.fn();
const mockTeamFindById = vi.fn();
let useCase: GetLeagueDriverSeasonStatsUseCase;
let standingRepository: IStandingRepository;
let resultRepository: IResultRepository;
let penaltyRepository: IPenaltyRepository;
let raceRepository: IRaceRepository;
let driverRepository: IDriverRepository;
let teamRepository: ITeamRepository;
let driverRatingPort: DriverRatingPort;
beforeEach(() => {
@@ -51,6 +57,12 @@ describe('GetLeagueDriverSeasonStatsUseCase', () => {
delete: vi.fn(),
exists: vi.fn(),
};
driverRepository = {
findById: mockDriverFindById,
};
teamRepository = {
findById: mockTeamFindById,
};
driverRatingPort = {
getRating: mockDriverRatingGetRating,
};
@@ -60,6 +72,8 @@ describe('GetLeagueDriverSeasonStatsUseCase', () => {
resultRepository,
penaltyRepository,
raceRepository,
driverRepository,
teamRepository,
driverRatingPort,
);
});
@@ -80,6 +94,8 @@ describe('GetLeagueDriverSeasonStatsUseCase', () => {
];
const mockResults = [{ position: 1 }];
const mockRating = { rating: 1500, ratingChange: 50 };
const mockDriver = { id: 'driver-1', name: 'Driver One', teamId: 'team-1' };
const mockTeam = { id: 'team-1', name: 'Team One' };
standingRepository.findByLeagueId.mockResolvedValue(mockStandings);
raceRepository.findByLeagueId.mockResolvedValue(mockRaces);
@@ -89,19 +105,39 @@ describe('GetLeagueDriverSeasonStatsUseCase', () => {
});
driverRatingPort.getRating.mockReturnValue(mockRating);
resultRepository.findByDriverIdAndLeagueId.mockResolvedValue(mockResults);
driverRepository.findById.mockImplementation((id) => {
if (id === 'driver-1') return Promise.resolve(mockDriver);
if (id === 'driver-2') return Promise.resolve({ id: 'driver-2', name: 'Driver Two' });
return Promise.resolve(null);
});
teamRepository.findById.mockResolvedValue(mockTeam);
const result = await useCase.execute(params);
expect(result.isOk()).toBe(true);
const dto = result.value!;
expect(dto.leagueId).toBe('league-1');
expect(dto.standings).toEqual([
{ driverId: 'driver-1', position: 1, points: 100, racesCompleted: 5 },
{ driverId: 'driver-2', position: 2, points: 80, racesCompleted: 5 },
]);
expect(dto.penalties.get('driver-1')).toEqual({ baseDelta: -10, bonusDelta: 0 });
expect(dto.driverRatings.get('driver-1')).toEqual(mockRating);
expect(dto.driverResults.get('driver-1')).toEqual(mockResults);
const output = result.value!;
expect(output.leagueId).toBe('league-1');
expect(output.stats).toHaveLength(2);
expect(output.stats[0]).toEqual({
leagueId: 'league-1',
driverId: 'driver-1',
position: 1,
driverName: 'Driver One',
teamId: 'team-1',
teamName: 'Team One',
totalPoints: 100,
basePoints: 90,
penaltyPoints: -10,
bonusPoints: 0,
pointsPerRace: 20,
racesStarted: 1,
racesFinished: 1,
dnfs: 0,
noShows: 1,
avgFinish: 1,
rating: 1500,
ratingChange: 50,
});
});
it('should handle no penalties', async () => {
@@ -111,17 +147,20 @@ describe('GetLeagueDriverSeasonStatsUseCase', () => {
const mockRaces = [{ id: 'race-1' }];
const mockResults = [{ position: 1 }];
const mockRating = { rating: null, ratingChange: null };
const mockDriver = { id: 'driver-1', name: 'Driver One' };
standingRepository.findByLeagueId.mockResolvedValue(mockStandings);
raceRepository.findByLeagueId.mockResolvedValue(mockRaces);
penaltyRepository.findByRaceId.mockResolvedValue([]);
driverRatingPort.getRating.mockReturnValue(mockRating);
resultRepository.findByDriverIdAndLeagueId.mockResolvedValue(mockResults);
driverRepository.findById.mockResolvedValue(mockDriver);
teamRepository.findById.mockResolvedValue(null);
const result = await useCase.execute(params);
expect(result.isOk()).toBe(true);
const dto = result.value!;
expect(dto.penalties.size).toBe(0);
const output = result.value!;
expect(output.stats[0].penaltyPoints).toBe(0);
});
});

View File

@@ -2,7 +2,9 @@ import type { IStandingRepository } from '../../domain/repositories/IStandingRep
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { LeagueDriverSeasonStatsResultDTO } from '../presenters/ILeagueDriverSeasonStatsPresenter';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { LeagueDriverSeasonStatsOutputPort } from '../ports/output/LeagueDriverSeasonStatsOutputPort';
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { DriverRatingPort } from '../ports/DriverRatingPort';
@@ -11,16 +13,18 @@ import type { DriverRatingPort } from '../ports/DriverRatingPort';
* Use Case for retrieving league driver season statistics.
* Orchestrates domain logic and returns the result.
*/
export class GetLeagueDriverSeasonStatsUseCase implements AsyncUseCase<{ leagueId: string }, LeagueDriverSeasonStatsResultDTO, 'NO_ERROR'> {
export class GetLeagueDriverSeasonStatsUseCase implements AsyncUseCase<{ leagueId: string }, LeagueDriverSeasonStatsOutputPort, 'NO_ERROR'> {
constructor(
private readonly standingRepository: IStandingRepository,
private readonly resultRepository: IResultRepository,
private readonly penaltyRepository: IPenaltyRepository,
private readonly raceRepository: IRaceRepository,
private readonly driverRepository: IDriverRepository,
private readonly teamRepository: ITeamRepository,
private readonly driverRatingPort: DriverRatingPort,
) {}
async execute(params: { leagueId: string }): Promise<Result<LeagueDriverSeasonStatsResultDTO, never>> {
async execute(params: { leagueId: string }): Promise<Result<LeagueDriverSeasonStatsOutputPort, never>> {
const { leagueId } = params;
// Get standings and races for the league
@@ -69,19 +73,54 @@ export class GetLeagueDriverSeasonStatsUseCase implements AsyncUseCase<{ leagueI
driverResults.set(standing.driverId, results);
}
const dto: LeagueDriverSeasonStatsResultDTO = {
leagueId,
standings: standings.map(standing => ({
// Fetch drivers and teams
const driverIds = standings.map(s => s.driverId);
const drivers = await Promise.all(driverIds.map(id => this.driverRepository.findById(id)));
const driversMap = new Map(drivers.filter(d => d).map(d => [d!.id, d!]));
const teamIds = Array.from(new Set(drivers.filter(d => d?.teamId).map(d => d!.teamId!)));
const teams = await Promise.all(teamIds.map(id => this.teamRepository.findById(id)));
const teamsMap = new Map(teams.filter(t => t).map(t => [t!.id, t!]));
// Compute stats
const stats = standings.map(standing => {
const driver = driversMap.get(standing.driverId);
const team = driver?.teamId ? teamsMap.get(driver.teamId) : undefined;
const penalties = penaltiesByDriver.get(standing.driverId) ?? { baseDelta: 0, bonusDelta: 0 };
const results = driverResults.get(standing.driverId) ?? [];
const rating = driverRatings.get(standing.driverId);
const racesStarted = results.length;
const racesFinished = results.filter(r => r.position > 0).length;
const dnfs = results.filter(r => r.position === 0).length;
const noShows = races.length - racesStarted;
const avgFinish = results.length > 0 ? results.reduce((sum, r) => sum + r.position, 0) / results.length : null;
const pointsPerRace = racesStarted > 0 ? standing.points / racesStarted : 0;
return {
leagueId,
driverId: standing.driverId,
position: standing.position,
points: standing.points,
racesCompleted: standing.racesCompleted,
})),
penalties: penaltiesByDriver,
driverResults,
driverRatings,
};
driverName: driver?.name ?? '',
teamId: driver?.teamId ?? undefined,
teamName: team?.name ?? undefined,
totalPoints: standing.points,
basePoints: standing.points - penalties.baseDelta,
penaltyPoints: penalties.baseDelta,
bonusPoints: penalties.bonusDelta,
pointsPerRace,
racesStarted,
racesFinished,
dnfs,
noShows,
avgFinish,
rating: rating?.rating ?? null,
ratingChange: rating?.ratingChange ?? null,
};
});
return Result.ok(dto);
return Result.ok({
leagueId,
stats,
});
}
}

View File

@@ -4,7 +4,7 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
import type { ILeagueFullConfigPresenter, LeagueConfigFormViewModel } from '../presenters/ILeagueFullConfigPresenter';
import type { LeagueFullConfigOutputPort } from '../ports/output/LeagueFullConfigOutputPort';
describe('GetLeagueFullConfigUseCase', () => {
let useCase: GetLeagueFullConfigUseCase;
@@ -12,7 +12,6 @@ describe('GetLeagueFullConfigUseCase', () => {
let seasonRepository: ISeasonRepository;
let leagueScoringConfigRepository: ILeagueScoringConfigRepository;
let gameRepository: IGameRepository;
let presenter: ILeagueFullConfigPresenter;
beforeEach(() => {
leagueRepository = {
@@ -31,19 +30,12 @@ describe('GetLeagueFullConfigUseCase', () => {
findById: vi.fn(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
presenter = {
reset: vi.fn(),
present: vi.fn(),
getViewModel: vi.fn(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
useCase = new GetLeagueFullConfigUseCase(
leagueRepository,
seasonRepository,
leagueScoringConfigRepository,
gameRepository,
presenter,
);
});
@@ -71,63 +63,17 @@ describe('GetLeagueFullConfigUseCase', () => {
const mockSeasons = [{ id: 'season-1', status: 'active', gameId: 'game-1' }];
const mockScoringConfig = { id: 'config-1' };
const mockGame = { id: 'game-1' };
const mockViewModel: LeagueConfigFormViewModel = {
leagueId: 'league-1',
basics: {
name: 'Test League',
description: 'A test league',
visibility: 'public',
gameId: 'iracing',
},
structure: {
mode: 'solo',
maxDrivers: 32,
multiClassEnabled: false,
},
championships: {
enableDriverChampionship: true,
enableTeamChampionship: false,
enableNationsChampionship: false,
enableTrophyChampionship: false,
},
scoring: {
customScoringEnabled: false,
},
dropPolicy: {
strategy: 'none',
},
timings: {
practiceMinutes: 30,
qualifyingMinutes: 15,
mainRaceMinutes: 60,
sessionCount: 1,
roundsPlanned: 10,
},
stewarding: {
decisionMode: 'admin_only',
requireDefense: false,
defenseTimeLimit: 48,
voteTimeLimit: 72,
protestDeadlineHours: 48,
stewardingClosesHours: 168,
notifyAccusedOnProtest: true,
notifyOnVoteRequired: true,
},
};
leagueRepository.findById.mockResolvedValue(mockLeague);
seasonRepository.findByLeagueId.mockResolvedValue(mockSeasons);
leagueScoringConfigRepository.findBySeasonId.mockResolvedValue(mockScoringConfig);
gameRepository.findById.mockResolvedValue(mockGame);
presenter.getViewModel.mockReturnValue(mockViewModel);
const result = await useCase.execute(params);
expect(result.isOk()).toBe(true);
const viewModel = result.value!;
expect(viewModel).toEqual(mockViewModel);
expect(presenter.reset).toHaveBeenCalled();
expect(presenter.present).toHaveBeenCalledWith({
const output = result.value!;
expect(output).toEqual({
league: mockLeague,
activeSeason: mockSeasons[0],
scoringConfig: mockScoringConfig,
@@ -157,25 +103,15 @@ describe('GetLeagueFullConfigUseCase', () => {
description: 'A test league',
settings: { maxDrivers: 32 },
};
const mockViewModel: LeagueConfigFormViewModel = {
leagueId: 'league-1',
basics: { name: 'Test League', description: 'A test league', visibility: 'public', gameId: 'iracing' },
structure: { mode: 'solo', maxDrivers: 32, multiClassEnabled: false },
championships: { enableDriverChampionship: true, enableTeamChampionship: false, enableNationsChampionship: false, enableTrophyChampionship: false },
scoring: { customScoringEnabled: false },
dropPolicy: { strategy: 'none' },
timings: { practiceMinutes: 30, qualifyingMinutes: 15, mainRaceMinutes: 60, sessionCount: 1, roundsPlanned: 10 },
stewarding: { decisionMode: 'admin_only', requireDefense: false, defenseTimeLimit: 48, voteTimeLimit: 72, protestDeadlineHours: 48, stewardingClosesHours: 168, notifyAccusedOnProtest: true, notifyOnVoteRequired: true },
};
leagueRepository.findById.mockResolvedValue(mockLeague);
seasonRepository.findByLeagueId.mockResolvedValue([]);
presenter.getViewModel.mockReturnValue(mockViewModel);
const result = await useCase.execute(params);
expect(result.isOk()).toBe(true);
expect(presenter.present).toHaveBeenCalledWith({
const output = result.value!;
expect(output).toEqual({
league: mockLeague,
});
});

View File

@@ -2,29 +2,24 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
import type {
ILeagueFullConfigPresenter,
LeagueFullConfigData,
LeagueConfigFormViewModel,
} from '../presenters/ILeagueFullConfigPresenter';
import type { LeagueFullConfigOutputPort } from '../ports/output/LeagueFullConfigOutputPort';
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
/**
* Use Case for retrieving a league's full configuration.
* Orchestrates domain logic and delegates presentation to the presenter.
* Orchestrates domain logic and returns the configuration data.
*/
export class GetLeagueFullConfigUseCase implements AsyncUseCase<{ leagueId: string }, LeagueConfigFormViewModel, 'LEAGUE_NOT_FOUND' | 'PRESENTATION_FAILED'> {
export class GetLeagueFullConfigUseCase implements AsyncUseCase<{ leagueId: string }, LeagueFullConfigOutputPort, 'LEAGUE_NOT_FOUND'> {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
private readonly gameRepository: IGameRepository,
private readonly presenter: ILeagueFullConfigPresenter,
) {}
async execute(params: { leagueId: string }): Promise<Result<LeagueConfigFormViewModel, ApplicationErrorCode<'LEAGUE_NOT_FOUND' | 'PRESENTATION_FAILED', { message: string }>>> {
async execute(params: { leagueId: string }): Promise<Result<LeagueFullConfigOutputPort, ApplicationErrorCode<'LEAGUE_NOT_FOUND', { message: string }>>> {
const { leagueId } = params;
const league = await this.leagueRepository.findById(leagueId);
@@ -47,20 +42,13 @@ export class GetLeagueFullConfigUseCase implements AsyncUseCase<{ leagueId: stri
return this.gameRepository.findById(activeSeason.gameId);
})();
const data: LeagueFullConfigData = {
const output: LeagueFullConfigOutputPort = {
league,
...(activeSeason ? { activeSeason } : {}),
...(scoringConfig ? { scoringConfig } : {}),
...(game ? { game } : {}),
};
this.presenter.reset();
this.presenter.present(data);
const viewModel = this.presenter.getViewModel();
if (!viewModel) {
return Result.err({ code: 'PRESENTATION_FAILED', details: { message: 'Failed to present league config' } });
}
return Result.ok(viewModel);
return Result.ok(output);
}
}

View File

@@ -13,7 +13,7 @@ export class GetLeagueOwnerSummaryUseCase implements AsyncUseCase<GetLeagueOwner
async execute(params: GetLeagueOwnerSummaryUseCaseParams): Promise<Result<GetLeagueOwnerSummaryOutputPort, ApplicationErrorCode<'NO_ERROR'>>> {
const driver = await this.driverRepository.findById(params.ownerId);
const summary = driver ? { driver: { id: driver.id, name: driver.name }, rating: 0, rank: 0 } : null;
const summary = driver ? { driver: { id: driver.id, iracingId: driver.iracingId.toString(), name: driver.name.toString(), country: driver.country.toString(), bio: driver.bio?.toString(), joinedAt: driver.joinedAt.toDate().toISOString() }, rating: 0, rank: 0 } : null;
return Result.ok({ summary });
}
}

View File

@@ -4,27 +4,41 @@ import type { IDriverRepository } from '../../domain/repositories/IDriverReposit
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { GetLeagueProtestsResultDTO, ProtestDTO } from '../dto/GetLeagueProtestsResultDTO';
import type { GetLeagueProtestsOutputPort, ProtestOutputPort, RaceOutputPort, DriverOutputPort } from '../ports/output/GetLeagueProtestsOutputPort';
export interface GetLeagueProtestsUseCaseParams {
leagueId: string;
}
export class GetLeagueProtestsUseCase implements AsyncUseCase<GetLeagueProtestsUseCaseParams, GetLeagueProtestsResultDTO, 'NO_ERROR'> {
export class GetLeagueProtestsUseCase implements AsyncUseCase<GetLeagueProtestsUseCaseParams, GetLeagueProtestsOutputPort, 'NO_ERROR'> {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly protestRepository: IProtestRepository,
private readonly driverRepository: IDriverRepository,
) {}
async execute(params: GetLeagueProtestsUseCaseParams): Promise<Result<GetLeagueProtestsResultDTO, ApplicationErrorCode<'NO_ERROR'>>> {
async execute(params: GetLeagueProtestsUseCaseParams): Promise<Result<GetLeagueProtestsOutputPort, ApplicationErrorCode<'NO_ERROR'>>> {
const races = await this.raceRepository.findByLeagueId(params.leagueId);
const protests: ProtestDTO[] = [];
const raceMap = new Map();
const protests: ProtestOutputPort[] = [];
const racesById: Record<string, RaceOutputPort> = {};
const driversById: Record<string, DriverOutputPort> = {};
const driverIds = new Set<string>();
for (const race of races) {
raceMap.set(race.id, { id: race.id, name: race.track, date: race.scheduledAt.toISOString() });
racesById[race.id] = {
id: race.id,
leagueId: race.leagueId,
scheduledAt: race.scheduledAt.toISOString(),
track: race.track,
trackId: race.trackId,
car: race.car,
carId: race.carId,
sessionType: race.sessionType.toString(),
status: race.status,
strengthOfField: race.strengthOfField,
registeredCount: race.registeredCount,
maxParticipants: race.maxParticipants,
};
const raceProtests = await this.protestRepository.findByRaceId(race.id);
for (const protest of raceProtests) {
protests.push({
@@ -32,26 +46,48 @@ export class GetLeagueProtestsUseCase implements AsyncUseCase<GetLeagueProtestsU
raceId: protest.raceId,
protestingDriverId: protest.protestingDriverId,
accusedDriverId: protest.accusedDriverId,
submittedAt: protest.filedAt,
description: protest.comment || '',
status: protest.status,
incident: {
lap: protest.incident.lap,
description: protest.incident.description,
timeInRace: protest.incident.timeInRace,
},
comment: protest.comment,
proofVideoUrl: protest.proofVideoUrl,
status: protest.status.toString(),
reviewedBy: protest.reviewedBy,
decisionNotes: protest.decisionNotes,
filedAt: protest.filedAt.toISOString(),
reviewedAt: protest.reviewedAt?.toISOString(),
defense: protest.defense ? {
statement: protest.defense.statement.toString(),
videoUrl: protest.defense.videoUrl?.toString(),
submittedAt: protest.defense.submittedAt.toDate().toISOString(),
} : undefined,
defenseRequestedAt: protest.defenseRequestedAt?.toISOString(),
defenseRequestedBy: protest.defenseRequestedBy,
});
driverIds.add(protest.protestingDriverId);
driverIds.add(protest.accusedDriverId);
}
}
const drivers: { id: string; name: string }[] = [];
for (const driverId of driverIds) {
const driver = await this.driverRepository.findById(driverId);
if (driver) {
drivers.push({ id: driver.id, name: driver.name });
driversById[driver.id] = {
id: driver.id,
iracingId: driver.iracingId.toString(),
name: driver.name.toString(),
country: driver.country.toString(),
bio: driver.bio?.toString(),
joinedAt: driver.joinedAt.toDate().toISOString(),
};
}
}
return Result.ok({
protests,
races: Array.from(raceMap.values()),
drivers,
racesById,
driversById,
});
}
}

View File

@@ -2,16 +2,16 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { GetLeagueScheduleResultDTO } from '../dto/GetLeagueScheduleResultDTO';
import type { GetLeagueScheduleOutputPort } from '../ports/output/GetLeagueScheduleOutputPort';
export interface GetLeagueScheduleUseCaseParams {
leagueId: string;
}
export class GetLeagueScheduleUseCase implements AsyncUseCase<GetLeagueScheduleUseCaseParams, GetLeagueScheduleResultDTO, 'NO_ERROR'> {
export class GetLeagueScheduleUseCase implements AsyncUseCase<GetLeagueScheduleUseCaseParams, GetLeagueScheduleOutputPort, 'NO_ERROR'> {
constructor(private readonly raceRepository: IRaceRepository) {}
async execute(params: GetLeagueScheduleUseCaseParams): Promise<Result<GetLeagueScheduleResultDTO, ApplicationErrorCode<'NO_ERROR'>>> {
async execute(params: GetLeagueScheduleUseCaseParams): Promise<Result<GetLeagueScheduleOutputPort, ApplicationErrorCode<'NO_ERROR'>>> {
const races = await this.raceRepository.findByLeagueId(params.leagueId);
return Result.ok({
races: races.map(race => ({

View File

@@ -4,7 +4,7 @@ import type { ILeagueScoringConfigRepository } from '../../domain/repositories/I
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
import type { GetLeagueScoringPresetByIdInputPort } from '../ports/input/GetLeagueScoringPresetByIdInputPort';
import type { LeagueScoringPresetOutputPort } from '../ports/output/LeagueScoringPresetOutputPort';
import type { LeagueScoringConfigData } from '../presenters/ILeagueScoringConfigPresenter';
import type { LeagueScoringConfigOutputPort } from '../ports/output/LeagueScoringConfigOutputPort';
import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -20,7 +20,7 @@ type GetLeagueScoringConfigErrorCode =
* Use Case for retrieving a league's scoring configuration for its active season.
*/
export class GetLeagueScoringConfigUseCase
implements AsyncUseCase<{ leagueId: string }, LeagueScoringConfigData, GetLeagueScoringConfigErrorCode>
implements AsyncUseCase<{ leagueId: string }, LeagueScoringConfigOutputPort, GetLeagueScoringConfigErrorCode>
{
constructor(
private readonly leagueRepository: ILeagueRepository,
@@ -30,7 +30,7 @@ export class GetLeagueScoringConfigUseCase
private readonly getLeagueScoringPresetById: (input: GetLeagueScoringPresetByIdInputPort) => Promise<LeagueScoringPresetOutputPort | undefined>,
) {}
async execute(params: { leagueId: string }): Promise<Result<LeagueScoringConfigData, ApplicationErrorCode<GetLeagueScoringConfigErrorCode>>> {
async execute(params: { leagueId: string }): Promise<Result<LeagueScoringConfigOutputPort, ApplicationErrorCode<GetLeagueScoringConfigErrorCode>>> {
const { leagueId } = params;
const league = await this.leagueRepository.findById(leagueId);
@@ -64,7 +64,7 @@ export class GetLeagueScoringConfigUseCase
const presetId = scoringConfig.scoringPresetId;
const preset = presetId ? await this.getLeagueScoringPresetById({ presetId }) : undefined;
const data: LeagueScoringConfigData = {
const output: LeagueScoringConfigOutputPort = {
leagueId: league.id,
seasonId: activeSeason.id,
gameId: game.id,
@@ -74,6 +74,6 @@ export class GetLeagueScoringConfigUseCase
championships: scoringConfig.championships,
};
return Result.ok(data);
return Result.ok(output);
}
}

View File

@@ -1,5 +1,5 @@
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { GetLeagueSeasonsViewModel } from '../presenters/IGetLeagueSeasonsPresenter';
import type { GetLeagueSeasonsOutputPort } from '../ports/output/GetLeagueSeasonsOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -10,11 +10,11 @@ export interface GetLeagueSeasonsUseCaseParams {
export class GetLeagueSeasonsUseCase {
constructor(private readonly seasonRepository: ISeasonRepository) {}
async execute(params: GetLeagueSeasonsUseCaseParams): Promise<Result<GetLeagueSeasonsViewModel, ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>>> {
async execute(params: GetLeagueSeasonsUseCaseParams): Promise<Result<GetLeagueSeasonsOutputPort, ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>>> {
try {
const seasons = await this.seasonRepository.findByLeagueId(params.leagueId);
const activeCount = seasons.filter(s => s.status === 'active').length;
const viewModel: GetLeagueSeasonsViewModel = {
const output: GetLeagueSeasonsOutputPort = {
seasons: seasons.map(s => ({
seasonId: s.id,
name: s.name,
@@ -25,7 +25,7 @@ export class GetLeagueSeasonsUseCase {
isParallelActive: s.status === 'active' && activeCount > 1
}))
};
return Result.ok(viewModel);
return Result.ok(output);
} catch {
return Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'Failed to fetch seasons' } });
}

View File

@@ -1,6 +1,6 @@
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { LeagueStandingsViewModel } from '../presenters/ILeagueStandingsPresenter';
import type { LeagueStandingsOutputPort } from '../ports/output/LeagueStandingsOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -19,7 +19,7 @@ export class GetLeagueStandingsUseCase {
async execute(
params: GetLeagueStandingsUseCaseParams,
): Promise<Result<LeagueStandingsViewModel, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
): Promise<Result<LeagueStandingsOutputPort, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
try {
const standings = await this.standingRepository.findByLeagueId(params.leagueId);
const driverIds = [...new Set(standings.map(s => s.driverId))];
@@ -27,7 +27,7 @@ export class GetLeagueStandingsUseCase {
const driverResults = await Promise.all(driverPromises);
const drivers = driverResults.filter((d): d is NonNullable<typeof d> => d !== null);
const driverMap = new Map(drivers.map(d => [d.id, { id: d.id, name: d.name }]));
const viewModel: LeagueStandingsViewModel = {
const viewModel: LeagueStandingsOutputPort = {
standings: standings.map(s => ({
driverId: s.driverId,
driver: driverMap.get(s.driverId)!,

View File

@@ -1,6 +1,6 @@
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { LeagueStatsViewModel } from '../presenters/ILeagueStatsPresenter';
import type { LeagueStatsOutputPort } from '../ports/output/LeagueStatsOutputPort';
import type { GetDriverRatingInputPort } from '../ports/input/GetDriverRatingInputPort';
import type { GetDriverRatingOutputPort } from '../ports/output/GetDriverRatingOutputPort';
import { Result } from '@core/shared/application/Result';
@@ -17,7 +17,7 @@ export class GetLeagueStatsUseCase {
private readonly getDriverRating: (input: GetDriverRatingInputPort) => Promise<GetDriverRatingOutputPort>,
) {}
async execute(params: GetLeagueStatsUseCaseParams): Promise<Result<LeagueStatsViewModel, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
async execute(params: GetLeagueStatsUseCaseParams): Promise<Result<LeagueStatsOutputPort, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
try {
const memberships = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId);
const races = await this.raceRepository.findByLeagueId(params.leagueId);
@@ -35,7 +35,7 @@ export class GetLeagueStatsUseCase {
const averageRating = validRatings.length > 0 ? Math.round(validRatings.reduce((sum, r) => sum + r, 0) / validRatings.length) : 0;
const viewModel: LeagueStatsViewModel = {
const viewModel: LeagueStatsOutputPort = {
totalMembers: memberships.length,
totalRaces: races.length,
averageRating,

View File

@@ -8,7 +8,7 @@ import type { ISponsorshipRequestRepository } from '../../domain/repositories/IS
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship';
import type { PendingSponsorshipRequestsViewModel } from '../presenters/IPendingSponsorshipRequestsPresenter';
import type { PendingSponsorshipRequestsOutputPort } from '../ports/output/PendingSponsorshipRequestsOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -47,7 +47,7 @@ export class GetPendingSponsorshipRequestsUseCase {
async execute(
dto: GetPendingSponsorshipRequestsDTO,
): Promise<Result<PendingSponsorshipRequestsViewModel, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
): Promise<Result<PendingSponsorshipRequestsOutputPort, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
try {
const requests = await this.sponsorshipRequestRepo.findPendingByEntity(
dto.entityType,
@@ -78,13 +78,13 @@ export class GetPendingSponsorshipRequestsUseCase {
// Sort by creation date (newest first)
requestDTOs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
const viewModel: PendingSponsorshipRequestsViewModel = {
const outputPort: PendingSponsorshipRequestsOutputPort = {
entityType: dto.entityType,
entityId: dto.entityId,
requests: requestDTOs,
totalCount: requestDTOs.length,
};
return Result.ok(viewModel);
return Result.ok(outputPort);
} catch {
return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch pending sponsorship requests' });
}

View File

@@ -5,14 +5,7 @@ import type { IImageServicePort } from '../ports/IImageServicePort';
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
import type { Driver } from '../../domain/entities/Driver';
import type { Team } from '../../domain/entities/Team';
import type {
ProfileOverviewViewModel,
ProfileOverviewDriverSummaryViewModel,
ProfileOverviewStatsViewModel,
ProfileOverviewFinishDistributionViewModel,
ProfileOverviewTeamMembershipViewModel,
ProfileOverviewSocialSummaryViewModel,
} from '../presenters/IProfileOverviewPresenter';
import type { ProfileOverviewOutputPort } from '../ports/output/ProfileOverviewOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -51,7 +44,7 @@ export class GetProfileOverviewUseCase {
private readonly getAllDriverRankings: () => DriverRankingEntry[],
) {}
async execute(params: GetProfileOverviewParams): Promise<Result<ProfileOverviewViewModel, ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR'>>> {
async execute(params: GetProfileOverviewParams): Promise<Result<ProfileOverviewOutputPort, ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR'>>> {
try {
const { driverId } = params;
@@ -73,8 +66,8 @@ export class GetProfileOverviewUseCase {
const teamMemberships = await this.buildTeamMemberships(driver.id, teams as Team[]);
const socialSummary = this.buildSocialSummary(friends as Driver[]);
const viewModel: ProfileOverviewViewModel = {
currentDriver: driverSummary,
const outputPort: ProfileOverviewOutputPort = {
driver: driverSummary,
stats,
finishDistribution,
teamMemberships,
@@ -82,7 +75,7 @@ export class GetProfileOverviewUseCase {
extendedProfile: null,
};
return Result.ok(viewModel);
return Result.ok(outputPort);
} catch {
return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch profile overview' });
}
@@ -91,25 +84,25 @@ export class GetProfileOverviewUseCase {
private buildDriverSummary(
driver: Driver,
stats: ProfileDriverStatsAdapter | null,
): ProfileOverviewDriverSummaryViewModel {
): ProfileOverviewOutputPort['driver'] {
const rankings = this.getAllDriverRankings();
const fallbackRank = this.computeFallbackRank(driver.id, rankings);
const totalDrivers = rankings.length;
return {
id: driver.id,
name: driver.name,
country: driver.country,
name: driver.name.value,
country: driver.country.value,
avatarUrl: this.imageService.getDriverAvatar(driver.id),
iracingId: driver.iracingId ?? null,
iracingId: driver.iracingId?.value ?? null,
joinedAt:
driver.joinedAt instanceof Date
? driver.joinedAt.toISOString()
: new Date(driver.joinedAt).toISOString(),
? driver.joinedAt
: new Date(driver.joinedAt.value),
rating: stats?.rating ?? null,
globalRank: stats?.overallRank ?? fallbackRank,
consistency: stats?.consistency ?? null,
bio: driver.bio ?? null,
bio: driver.bio?.value ?? null,
totalDrivers,
};
}
@@ -193,8 +186,8 @@ export class GetProfileOverviewUseCase {
private async buildTeamMemberships(
driverId: string,
teams: Team[],
): Promise<ProfileOverviewTeamMembershipViewModel[]> {
const memberships: ProfileOverviewTeamMembershipViewModel[] = [];
): Promise<ProfileOverviewOutputPort['teamMemberships']> {
const memberships: ProfileOverviewOutputPort['teamMemberships'] = [];
for (const team of teams) {
const membership = await this.teamMembershipRepository.getMembership(
@@ -205,29 +198,29 @@ export class GetProfileOverviewUseCase {
memberships.push({
teamId: team.id,
teamName: team.name,
teamTag: team.tag ?? null,
teamName: team.name.value,
teamTag: team.tag?.value ?? null,
role: membership.role,
joinedAt:
membership.joinedAt instanceof Date
? membership.joinedAt.toISOString()
: new Date(membership.joinedAt).toISOString(),
? membership.joinedAt
: new Date(membership.joinedAt),
isCurrent: membership.status === 'active',
});
}
memberships.sort((a, b) => a.joinedAt.localeCompare(b.joinedAt));
memberships.sort((a, b) => a.joinedAt.getTime() - b.joinedAt.getTime());
return memberships;
}
private buildSocialSummary(friends: Driver[]): ProfileOverviewSocialSummaryViewModel {
private buildSocialSummary(friends: Driver[]): ProfileOverviewOutputPort['socialSummary'] {
return {
friendsCount: friends.length,
friends: friends.map(friend => ({
id: friend.id,
name: friend.name,
country: friend.country,
name: friend.name.value,
country: friend.country.value,
avatarUrl: this.imageService.getDriverAvatar(friend.id),
})),
};

View File

@@ -4,17 +4,7 @@ import type { IDriverRepository } from '../../domain/repositories/IDriverReposit
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { GetDriverRatingInputPort } from '../ports/input/GetDriverRatingInputPort';
import type { GetDriverRatingOutputPort } from '../ports/output/GetDriverRatingOutputPort';
import type { GetDriverAvatarInputPort } from '../ports/input/GetDriverAvatarInputPort';
import type { GetDriverAvatarOutputPort } from '../ports/output/GetDriverAvatarOutputPort';
import type {
RaceDetailViewModel,
RaceDetailRaceViewModel,
RaceDetailLeagueViewModel,
RaceDetailEntryViewModel,
RaceDetailUserResultViewModel,
} from '../presenters/IRaceDetailPresenter';
import type { RaceDetailOutputPort } from '../ports/output/RaceDetailOutputPort';
import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -37,7 +27,7 @@ export interface GetRaceDetailQueryParams {
type GetRaceDetailErrorCode = 'RACE_NOT_FOUND';
export class GetRaceDetailUseCase
implements AsyncUseCase<GetRaceDetailQueryParams, RaceDetailViewModel, GetRaceDetailErrorCode>
implements AsyncUseCase<GetRaceDetailQueryParams, RaceDetailOutputPort, GetRaceDetailErrorCode>
{
constructor(
private readonly raceRepository: IRaceRepository,
@@ -46,11 +36,9 @@ export class GetRaceDetailUseCase
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
private readonly resultRepository: IResultRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly getDriverRating: (input: GetDriverRatingInputPort) => Promise<GetDriverRatingOutputPort>,
private readonly getDriverAvatar: (input: GetDriverAvatarInputPort) => Promise<GetDriverAvatarOutputPort>,
) {}
async execute(params: GetRaceDetailQueryParams): Promise<Result<RaceDetailViewModel, ApplicationErrorCode<GetRaceDetailErrorCode>>> {
async execute(params: GetRaceDetailQueryParams): Promise<Result<RaceDetailOutputPort, ApplicationErrorCode<GetRaceDetailErrorCode>>> {
const { raceId, driverId } = params;
const race = await this.raceRepository.findById(raceId);
@@ -58,105 +46,40 @@ export class GetRaceDetailUseCase
return Result.err({ code: 'RACE_NOT_FOUND' });
}
const [league, registeredDriverIds, membership] = await Promise.all([
const [league, registrations, membership] = await Promise.all([
this.leagueRepository.findById(race.leagueId),
this.raceRegistrationRepository.getRegisteredDrivers(race.id),
this.raceRegistrationRepository.findByRaceId(race.id),
this.leagueMembershipRepository.getMembership(race.leagueId, driverId),
]);
const drivers = await Promise.all(
registeredDriverIds.map(id => this.driverRepository.findById(id)),
registrations.map(registration => this.driverRepository.findById(registration.driverId.toString())),
);
const entryList: RaceDetailEntryViewModel[] = [];
for (const driver of drivers) {
if (driver) {
const ratingResult = await this.getDriverRating({ driverId: driver.id });
const avatarResult = await this.getDriverAvatar({ driverId: driver.id });
entryList.push({
id: driver.id,
name: driver.name,
country: driver.country,
avatarUrl: avatarResult.avatarUrl,
rating: ratingResult.rating,
isCurrentUser: driver.id === driverId,
});
}
}
const validDrivers = drivers.filter((driver): driver is NonNullable<typeof driver> => driver !== null);
const isUserRegistered = registeredDriverIds.includes(driverId);
const isUserRegistered = registrations.some(reg => reg.driverId.toString() === driverId);
const isUpcoming = race.status === 'scheduled' && race.scheduledAt > new Date();
const canRegister = !!membership && membership.status === 'active' && isUpcoming;
let userResultView: RaceDetailUserResultViewModel | null = null;
let userResult: Result | null = null;
if (race.status === 'completed') {
const results = await this.resultRepository.findByRaceId(race.id);
const userResult = results.find(r => r.driverId === driverId) ?? null;
if (userResult) {
const ratingChange = this.calculateRatingChange(userResult.position);
userResultView = {
position: userResult.position,
startPosition: userResult.startPosition,
incidents: userResult.incidents,
fastestLap: userResult.fastestLap,
positionChange: userResult.getPositionChange(),
isPodium: userResult.isPodium(),
isClean: userResult.isClean(),
ratingChange,
};
}
userResult = results.find(r => r.driverId.toString() === driverId) ?? null;
}
const raceView: RaceDetailRaceViewModel = {
id: race.id,
leagueId: race.leagueId,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt.toISOString(),
sessionType: race.sessionType,
status: race.status,
strengthOfField: race.strengthOfField ?? null,
...(race.registeredCount !== undefined ? { registeredCount: race.registeredCount } : {}),
...(race.maxParticipants !== undefined ? { maxParticipants: race.maxParticipants } : {}),
const outputPort: RaceDetailOutputPort = {
race,
league,
registrations,
drivers: validDrivers,
userResult,
isUserRegistered,
canRegister,
};
const leagueView: RaceDetailLeagueViewModel | null = league
? {
id: league.id,
name: league.name,
description: league.description,
settings: {
...(league.settings.maxDrivers !== undefined
? { maxDrivers: league.settings.maxDrivers }
: {}),
...(league.settings.qualifyingFormat !== undefined
? { qualifyingFormat: league.settings.qualifyingFormat }
: {}),
},
}
: null;
const viewModel: RaceDetailViewModel = {
race: raceView,
league: leagueView,
entryList,
registration: {
isUserRegistered,
canRegister,
},
userResult: userResultView,
};
return Result.ok(viewModel);
return Result.ok(outputPort);
}
private calculateRatingChange(position: number): number {
const baseChange = position <= 3 ? 25 : position <= 10 ? 10 : -5;
const positionBonus = Math.max(0, (20 - position) * 2);
return baseChange + positionBonus;
}
}

View File

@@ -7,7 +7,7 @@
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { RacePenaltiesResultDTO } from '../presenters/IRacePenaltiesPresenter';
import type { RacePenaltiesOutputPort } from '../ports/output/RacePenaltiesOutputPort';
import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -16,13 +16,13 @@ export interface GetRacePenaltiesInput {
raceId: string;
}
export class GetRacePenaltiesUseCase implements AsyncUseCase<GetRacePenaltiesInput, RacePenaltiesResultDTO, 'NO_ERROR'> {
export class GetRacePenaltiesUseCase implements AsyncUseCase<GetRacePenaltiesInput, RacePenaltiesOutputPort, 'NO_ERROR'> {
constructor(
private readonly penaltyRepository: IPenaltyRepository,
private readonly driverRepository: IDriverRepository,
) {}
async execute(input: GetRacePenaltiesInput): Promise<Result<RacePenaltiesResultDTO, ApplicationErrorCode<'NO_ERROR'>>> {
async execute(input: GetRacePenaltiesInput): Promise<Result<RacePenaltiesOutputPort, ApplicationErrorCode<'NO_ERROR'>>> {
const penalties = await this.penaltyRepository.findByRaceId(input.raceId);
const driverIds = new Set<string>();
@@ -35,17 +35,12 @@ export class GetRacePenaltiesUseCase implements AsyncUseCase<GetRacePenaltiesInp
Array.from(driverIds).map((id) => this.driverRepository.findById(id)),
);
const driverMap = new Map<string, string>();
drivers.forEach((driver) => {
if (driver) {
driverMap.set(driver.id, driver.name);
}
});
const validDrivers = drivers.filter((driver): driver is NonNullable<typeof driver> => driver !== null);
const dto: RacePenaltiesResultDTO = {
const outputPort: RacePenaltiesOutputPort = {
penalties,
driverMap,
drivers: validDrivers,
};
return Result.ok(dto);
return Result.ok(outputPort);
}
}

View File

@@ -7,7 +7,7 @@
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { RaceProtestsResultDTO } from '../presenters/IRaceProtestsPresenter';
import type { RaceProtestsOutputPort } from '../ports/output/RaceProtestsOutputPort';
import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -16,13 +16,13 @@ export interface GetRaceProtestsInput {
raceId: string;
}
export class GetRaceProtestsUseCase implements AsyncUseCase<GetRaceProtestsInput, RaceProtestsResultDTO, 'NO_ERROR'> {
export class GetRaceProtestsUseCase implements AsyncUseCase<GetRaceProtestsInput, RaceProtestsOutputPort, 'NO_ERROR'> {
constructor(
private readonly protestRepository: IProtestRepository,
private readonly driverRepository: IDriverRepository,
) {}
async execute(input: GetRaceProtestsInput): Promise<Result<RaceProtestsResultDTO, ApplicationErrorCode<'NO_ERROR'>>> {
async execute(input: GetRaceProtestsInput): Promise<Result<RaceProtestsOutputPort, ApplicationErrorCode<'NO_ERROR'>>> {
const protests = await this.protestRepository.findByRaceId(input.raceId);
const driverIds = new Set<string>();
@@ -38,17 +38,12 @@ export class GetRaceProtestsUseCase implements AsyncUseCase<GetRaceProtestsInput
Array.from(driverIds).map((id) => this.driverRepository.findById(id)),
);
const driverMap = new Map<string, string>();
drivers.forEach((driver) => {
if (driver) {
driverMap.set(driver.id, driver.name);
}
});
const validDrivers = drivers.filter((driver): driver is NonNullable<typeof driver> => driver !== null);
const dto: RaceProtestsResultDTO = {
const outputPort: RaceProtestsOutputPort = {
protests,
driverMap,
drivers: validDrivers,
};
return Result.ok(dto);
return Result.ok(outputPort);
}
}

View File

@@ -1,38 +1,42 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetRaceRegistrationsUseCase } from './GetRaceRegistrationsUseCase';
import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
import { RaceRegistration } from '@core/racing/domain/entities/RaceRegistration';
describe('GetRaceRegistrationsUseCase', () => {
let useCase: GetRaceRegistrationsUseCase;
let registrationRepository: { getRegisteredDrivers: Mock };
let registrationRepository: { findByRaceId: Mock };
beforeEach(() => {
registrationRepository = { getRegisteredDrivers: vi.fn() };
registrationRepository = { findByRaceId: vi.fn() };
useCase = new GetRaceRegistrationsUseCase(
registrationRepository as unknown as IRaceRegistrationRepository,
);
});
it('should return registered driver ids', async () => {
it('should return registrations', async () => {
const raceId = 'race-1';
const registeredDriverIds = ['driver-1', 'driver-2'];
const registrations = [
RaceRegistration.create({ raceId, driverId: 'driver-1' }),
RaceRegistration.create({ raceId, driverId: 'driver-2' }),
];
registrationRepository.getRegisteredDrivers.mockResolvedValue(registeredDriverIds);
registrationRepository.findByRaceId.mockResolvedValue(registrations);
const result = await useCase.execute({ raceId });
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.registeredDriverIds).toEqual(registeredDriverIds);
const outputPort = result.unwrap();
expect(outputPort.registrations).toEqual(registrations);
});
it('should return empty array when no registrations', async () => {
registrationRepository.getRegisteredDrivers.mockResolvedValue([]);
registrationRepository.findByRaceId.mockResolvedValue([]);
const result = await useCase.execute({ raceId: 'race-1' });
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.registeredDriverIds).toEqual([]);
const outputPort = result.unwrap();
expect(outputPort.registrations).toEqual([]);
});
});

View File

@@ -1,6 +1,6 @@
import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
import type { GetRaceRegistrationsQueryParamsDTO } from '../dto/RaceRegistrationQueryDTO';
import type { RaceRegistrationsResultDTO } from '../presenters/IRaceRegistrationsPresenter';
import type { RaceRegistrationsOutputPort } from '../ports/output/RaceRegistrationsOutputPort';
import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -11,19 +11,19 @@ import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorC
* Returns registered driver IDs for a race.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetRaceRegistrationsUseCase implements AsyncUseCase<GetRaceRegistrationsQueryParamsDTO, RaceRegistrationsResultDTO, 'NO_ERROR'> {
export class GetRaceRegistrationsUseCase implements AsyncUseCase<GetRaceRegistrationsQueryParamsDTO, RaceRegistrationsOutputPort, 'NO_ERROR'> {
constructor(
private readonly registrationRepository: IRaceRegistrationRepository,
) {}
async execute(params: GetRaceRegistrationsQueryParamsDTO): Promise<Result<RaceRegistrationsResultDTO, ApplicationErrorCode<'NO_ERROR'>>> {
async execute(params: GetRaceRegistrationsQueryParamsDTO): Promise<Result<RaceRegistrationsOutputPort, ApplicationErrorCode<'NO_ERROR'>>> {
const { raceId } = params;
const registeredDriverIds = await this.registrationRepository.getRegisteredDrivers(raceId);
const registrations = await this.registrationRepository.findByRaceId(raceId);
const dto: RaceRegistrationsResultDTO = {
registeredDriverIds,
const outputPort: RaceRegistrationsOutputPort = {
registrations,
};
return Result.ok(dto);
return Result.ok(outputPort);
}
}

View File

@@ -3,7 +3,7 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { RaceResultsDetailViewModel, RaceResultsPenaltySummaryViewModel } from '../presenters/IRaceResultsDetailPresenter';
import type { RaceResultsDetailOutputPort } from '../ports/output/RaceResultsDetailOutputPort';
import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -19,7 +19,7 @@ export interface GetRaceResultsDetailParams {
type GetRaceResultsDetailErrorCode = 'RACE_NOT_FOUND';
export class GetRaceResultsDetailUseCase implements AsyncUseCase<GetRaceResultsDetailParams, RaceResultsDetailViewModel, GetRaceResultsDetailErrorCode> {
export class GetRaceResultsDetailUseCase implements AsyncUseCase<GetRaceResultsDetailParams, RaceResultsDetailOutputPort, GetRaceResultsDetailErrorCode> {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
@@ -28,7 +28,7 @@ export class GetRaceResultsDetailUseCase implements AsyncUseCase<GetRaceResultsD
private readonly penaltyRepository: IPenaltyRepository,
) {}
async execute(params: GetRaceResultsDetailParams): Promise<Result<RaceResultsDetailViewModel, ApplicationErrorCode<GetRaceResultsDetailErrorCode>>> {
async execute(params: GetRaceResultsDetailParams): Promise<Result<RaceResultsDetailOutputPort, ApplicationErrorCode<GetRaceResultsDetailErrorCode>>> {
const { raceId, driverId } = params;
const race = await this.raceRepository.findById(raceId);
@@ -49,31 +49,19 @@ export class GetRaceResultsDetailUseCase implements AsyncUseCase<GetRaceResultsD
const pointsSystem = this.buildPointsSystem(league);
const fastestLapTime = this.getFastestLapTime(results);
const penaltySummary = this.mapPenaltySummary(penalties);
const viewModel: RaceResultsDetailViewModel = {
race: {
id: race.id,
leagueId: race.leagueId,
track: race.track,
scheduledAt: race.scheduledAt,
status: race.status,
},
league: league
? {
id: league.id,
name: league.name,
}
: null,
const outputPort: RaceResultsDetailOutputPort = {
race,
league,
results,
drivers,
penalties: penaltySummary,
penalties,
...(pointsSystem ? { pointsSystem } : {}),
...(fastestLapTime !== undefined ? { fastestLapTime } : {}),
...(effectiveCurrentDriverId ? { currentDriverId: effectiveCurrentDriverId } : {}),
};
return Result.ok(viewModel);
return Result.ok(outputPort);
}
private buildPointsSystem(league: League | null): Record<number, number> | undefined {
@@ -129,11 +117,4 @@ export class GetRaceResultsDetailUseCase implements AsyncUseCase<GetRaceResultsD
return Math.min(...results.map((r) => r.fastestLap));
}
private mapPenaltySummary(penalties: Penalty[]): RaceResultsPenaltySummaryViewModel[] {
return penalties.map((p) => ({
driverId: p.driverId,
type: p.type,
...(p.value !== undefined ? { value: p.value } : {}),
}));
}
}

View File

@@ -13,6 +13,7 @@ import type { IRaceRegistrationRepository } from '../../domain/repositories/IRac
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { GetDriverRatingInputPort } from '../ports/input/GetDriverRatingInputPort';
import type { GetDriverRatingOutputPort } from '../ports/output/GetDriverRatingOutputPort';
import type { RaceWithSOFOutputPort } from '../ports/output/RaceWithSOFOutputPort';
import {
AverageStrengthOfFieldCalculator,
type StrengthOfFieldCalculator,
@@ -22,25 +23,9 @@ export interface GetRaceWithSOFQueryParams {
raceId: string;
}
export interface RaceWithSOFResultDTO {
raceId: string;
leagueId: string;
scheduledAt: Date;
track: string;
trackId: string;
car: string;
carId: string;
sessionType: string;
status: string;
strengthOfField: number | null;
registeredCount: number;
maxParticipants: number;
participantCount: number;
}
type GetRaceWithSOFErrorCode = 'RACE_NOT_FOUND';
export class GetRaceWithSOFUseCase implements AsyncUseCase<GetRaceWithSOFQueryParams, RaceWithSOFResultDTO, GetRaceWithSOFErrorCode> {
export class GetRaceWithSOFUseCase implements AsyncUseCase<GetRaceWithSOFQueryParams, RaceWithSOFOutputPort, GetRaceWithSOFErrorCode> {
private readonly sofCalculator: StrengthOfFieldCalculator;
constructor(
@@ -53,7 +38,7 @@ export class GetRaceWithSOFUseCase implements AsyncUseCase<GetRaceWithSOFQueryPa
this.sofCalculator = sofCalculator ?? new AverageStrengthOfFieldCalculator();
}
async execute(params: GetRaceWithSOFQueryParams): Promise<Result<RaceWithSOFResultDTO, ApplicationErrorCode<GetRaceWithSOFErrorCode>>> {
async execute(params: GetRaceWithSOFQueryParams): Promise<Result<RaceWithSOFOutputPort, ApplicationErrorCode<GetRaceWithSOFErrorCode>>> {
const { raceId } = params;
const race = await this.raceRepository.findById(raceId);
@@ -93,15 +78,12 @@ export class GetRaceWithSOFUseCase implements AsyncUseCase<GetRaceWithSOFQueryPa
strengthOfField = this.sofCalculator.calculate(driverRatings);
}
const dto: RaceWithSOFResultDTO = {
raceId: race.id,
const outputPort: RaceWithSOFOutputPort = {
id: race.id,
leagueId: race.leagueId,
scheduledAt: race.scheduledAt,
track: race.track ?? '',
trackId: race.trackId ?? '',
car: race.car ?? '',
carId: race.carId ?? '',
sessionType: race.sessionType.props,
status: race.status,
strengthOfField,
registeredCount: race.registeredCount ?? participantIds.length,
@@ -109,6 +91,6 @@ export class GetRaceWithSOFUseCase implements AsyncUseCase<GetRaceWithSOFQueryPa
participantCount: participantIds.length,
};
return Result.ok(dto);
return Result.ok(outputPort);
}
}

View File

@@ -1,17 +1,17 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { RacesPageResultDTO } from '@core/racing/application/presenters/IRacesPagePresenter';
import type { RacesPageOutputPort } from '../ports/output/RacesPageOutputPort';
import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export class GetRacesPageDataUseCase implements AsyncUseCase<void, RacesPageResultDTO, 'NO_ERROR'> {
export class GetRacesPageDataUseCase implements AsyncUseCase<void, RacesPageOutputPort, 'NO_ERROR'> {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
) {}
async execute(): Promise<Result<RacesPageResultDTO, ApplicationErrorCode<'NO_ERROR'>>> {
async execute(): Promise<Result<RacesPageOutputPort, ApplicationErrorCode<'NO_ERROR'>>> {
const [allRaces, allLeagues] = await Promise.all([
this.raceRepository.findAll(),
this.leagueRepository.findAll(),
@@ -25,20 +25,19 @@ export class GetRacesPageDataUseCase implements AsyncUseCase<void, RacesPageResu
id: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt.toISOString(),
scheduledAt: race.scheduledAt,
status: race.status,
leagueId: race.leagueId,
leagueName: leagueMap.get(race.leagueId) ?? 'Unknown League',
strengthOfField: race.strengthOfField,
isUpcoming: race.isUpcoming(),
isLive: race.isLive(),
isPast: race.isPast(),
}));
const dto: RacesPageResultDTO = {
const outputPort: RacesPageOutputPort = {
page: 1,
pageSize: races.length,
totalCount: races.length,
races,
};
return Result.ok(dto);
return Result.ok(outputPort);
}
}

View File

@@ -10,7 +10,7 @@ import type { ISeasonRepository } from '../../domain/repositories/ISeasonReposit
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { SponsorDashboardViewModel } from '../presenters/ISponsorDashboardPresenter';
import type { SponsorDashboardOutputPort } from '../ports/output/SponsorDashboardOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -61,7 +61,7 @@ export class GetSponsorDashboardUseCase {
async execute(
params: GetSponsorDashboardQueryParams,
): Promise<Result<SponsorDashboardViewModel, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
): Promise<Result<SponsorDashboardOutputPort | null, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
try {
const { sponsorId } = params;
@@ -146,7 +146,7 @@ export class GetSponsorDashboardUseCase {
? Math.min(100, (mainSponsorships * 30) + (sponsorships.length * 10))
: 0;
const dto: SponsorDashboardDTO = {
const outputPort: SponsorDashboardOutputPort = {
sponsorId,
sponsorName: sponsor.name,
metrics: {
@@ -167,7 +167,7 @@ export class GetSponsorDashboardUseCase {
},
};
return Result.ok(dto);
return Result.ok(outputPort);
} catch {
return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch sponsor dashboard' });
}

View File

@@ -11,7 +11,7 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { SponsorshipTier, SponsorshipStatus } from '../../domain/entities/SeasonSponsorship';
import type { SponsorSponsorshipsViewModel } from '../presenters/ISponsorSponsorshipsPresenter';
import type { SponsorSponsorshipsOutputPort } from '../ports/output/SponsorSponsorshipsOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -76,7 +76,7 @@ export class GetSponsorSponsorshipsUseCase {
async execute(
params: GetSponsorSponsorshipsQueryParams,
): Promise<Result<SponsorSponsorshipsViewModel, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
): Promise<Result<SponsorSponsorshipsOutputPort | null, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
try {
const { sponsorId } = params;
@@ -153,7 +153,7 @@ export class GetSponsorSponsorshipsUseCase {
const activeSponsorships = sponsorships.filter(s => s.status === 'active').length;
const dto: SponsorSponsorshipsDTO = {
const outputPort: SponsorSponsorshipsOutputPort = {
sponsorId,
sponsorName: sponsor.name,
sponsorships: sponsorshipDetails,
@@ -166,7 +166,7 @@ export class GetSponsorSponsorshipsUseCase {
},
};
return Result.ok(dto);
return Result.ok(outputPort);
} catch {
return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch sponsor sponsorships' });
}

View File

@@ -5,7 +5,7 @@
*/
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import type { GetSponsorsViewModel } from '../presenters/IGetSponsorsPresenter';
import type { GetSponsorsOutputPort } from '../ports/output/GetSponsorsOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -14,11 +14,11 @@ export class GetSponsorsUseCase {
private readonly sponsorRepository: ISponsorRepository,
) {}
async execute(): Promise<Result<GetSponsorsViewModel, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
async execute(): Promise<Result<GetSponsorsOutputPort, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
try {
const sponsors = await this.sponsorRepository.findAll();
const viewModel: GetSponsorsViewModel = {
const outputPort: GetSponsorsOutputPort = {
sponsors: sponsors.map(sponsor => ({
id: sponsor.id,
name: sponsor.name,
@@ -29,7 +29,7 @@ export class GetSponsorsUseCase {
})),
};
return Result.ok(viewModel);
return Result.ok(outputPort);
} catch {
return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch sponsors' });
}

View File

@@ -4,15 +4,17 @@
* Retrieves general sponsorship pricing tiers.
*/
import type { GetSponsorshipPricingViewModel } from '../presenters/IGetSponsorshipPricingPresenter';
import type { GetSponsorshipPricingOutputPort } from '../ports/output/GetSponsorshipPricingOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export class GetSponsorshipPricingUseCase {
constructor() {}
async execute(): Promise<Result<GetSponsorshipPricingViewModel, ApplicationErrorCode<'NO_ERROR'>>> {
const viewModel: GetSponsorshipPricingViewModel = {
async execute(): Promise<Result<GetSponsorshipPricingOutputPort, ApplicationErrorCode<'NO_ERROR'>>> {
const outputPort: GetSponsorshipPricingOutputPort = {
entityType: 'season',
entityId: '',
pricing: [
{ id: 'tier-bronze', level: 'Bronze', price: 100, currency: 'USD' },
{ id: 'tier-silver', level: 'Silver', price: 250, currency: 'USD' },
@@ -20,6 +22,6 @@ export class GetSponsorshipPricingUseCase {
],
};
return Result.ok(viewModel);
return Result.ok(outputPort);
}
}

View File

@@ -1,6 +1,6 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { TeamDetailsViewModel } from '../presenters/ITeamDetailsPresenter';
import type { GetTeamDetailsOutputPort } from '../ports/output/GetTeamDetailsOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -15,7 +15,7 @@ export class GetTeamDetailsUseCase {
async execute(
params: { teamId: string; driverId: string },
): Promise<Result<TeamDetailsViewModel, ApplicationErrorCode<'TEAM_NOT_FOUND' | 'REPOSITORY_ERROR'>>> {
): Promise<Result<GetTeamDetailsOutputPort, ApplicationErrorCode<'TEAM_NOT_FOUND' | 'REPOSITORY_ERROR'>>> {
try {
const { teamId, driverId } = params;
const team = await this.teamRepository.findById(teamId);
@@ -25,25 +25,25 @@ export class GetTeamDetailsUseCase {
const membership = await this.membershipRepository.getMembership(teamId, driverId);
const viewModel: TeamDetailsViewModel = {
const outputPort: GetTeamDetailsOutputPort = {
team: {
id: team.id,
name: team.name,
tag: team.tag,
description: team.description,
name: team.name.value,
tag: team.tag?.value ?? '',
description: team.description?.value ?? '',
ownerId: team.ownerId,
leagues: team.leagues,
createdAt: team.createdAt.toISOString(),
leagues: team.leagues.map(l => l), // assuming leagues are strings
createdAt: team.createdAt instanceof Date ? team.createdAt : new Date(team.createdAt.value),
},
membership: membership ? {
role: membership.role as 'owner' | 'manager' | 'member',
joinedAt: membership.joinedAt.toISOString(),
joinedAt: membership.joinedAt instanceof Date ? membership.joinedAt : new Date(membership.joinedAt),
isActive: membership.status === 'active',
} : null,
canManage: membership ? membership.role === 'owner' || membership.role === 'manager' : false,
};
return Result.ok(viewModel);
return Result.ok(outputPort);
} catch {
return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch team details' });
}

View File

@@ -2,7 +2,7 @@ import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamM
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { GetDriverAvatarInputPort } from '../ports/input/GetDriverAvatarInputPort';
import type { GetDriverAvatarOutputPort } from '../ports/output/GetDriverAvatarOutputPort';
import type { TeamJoinRequestsResultDTO } from '../presenters/ITeamJoinRequestsPresenter';
import type { TeamJoinRequestsOutputPort } from '../ports/output/TeamJoinRequestsOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { AsyncUseCase } from '@core/shared/application';
@@ -11,7 +11,7 @@ import type { Logger } from '@core/shared/application';
/**
* Use Case for retrieving team join requests.
*/
export class GetTeamJoinRequestsUseCase implements AsyncUseCase<{ teamId: string }, TeamJoinRequestsResultDTO, 'REPOSITORY_ERROR'>
export class GetTeamJoinRequestsUseCase implements AsyncUseCase<{ teamId: string }, TeamJoinRequestsOutputPort, 'REPOSITORY_ERROR'>
{
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
@@ -20,7 +20,7 @@ export class GetTeamJoinRequestsUseCase implements AsyncUseCase<{ teamId: string
private readonly logger: Logger,
) {}
async execute(input: { teamId: string }): Promise<Result<TeamJoinRequestsResultDTO, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
async execute(input: { teamId: string }): Promise<Result<TeamJoinRequestsOutputPort, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
this.logger.debug('Executing GetTeamJoinRequestsUseCase', { teamId: input.teamId });
try {
@@ -33,7 +33,7 @@ export class GetTeamJoinRequestsUseCase implements AsyncUseCase<{ teamId: string
for (const request of requests) {
const driver = await this.driverRepository.findById(request.driverId);
if (driver) {
driverNames[request.driverId] = driver.name;
driverNames[request.driverId] = driver.name.value;
} else {
this.logger.warn(`Driver not found for ID: ${request.driverId} during join request processing.`);
}
@@ -43,13 +43,23 @@ export class GetTeamJoinRequestsUseCase implements AsyncUseCase<{ teamId: string
this.logger.debug('Processed driver details for join request', { driverId: request.driverId });
}
const dto: TeamJoinRequestsResultDTO = {
requests,
driverNames,
avatarUrls,
const requestsViewModel = requests.map(request => ({
requestId: request.id,
driverId: request.driverId,
driverName: driverNames[request.driverId] || 'Unknown',
teamId: request.teamId,
status: request.status as 'pending' | 'approved' | 'rejected',
requestedAt: request.requestedAt instanceof Date ? request.requestedAt : new Date(request.requestedAt),
avatarUrl: avatarUrls[request.driverId] || '',
}));
const outputPort: TeamJoinRequestsOutputPort = {
requests: requestsViewModel,
pendingCount: requests.filter(r => r.status === 'pending').length,
totalCount: requests.length,
};
return Result.ok(dto);
return Result.ok(outputPort);
} catch (error) {
this.logger.error('Error retrieving team join requests', { teamId: input.teamId, err: error });
return Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'Failed to retrieve team join requests' } });

View File

@@ -2,7 +2,7 @@ import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamM
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { GetDriverAvatarInputPort } from '../ports/input/GetDriverAvatarInputPort';
import type { GetDriverAvatarOutputPort } from '../ports/output/GetDriverAvatarOutputPort';
import type { TeamMembersResultDTO } from '../presenters/ITeamMembersPresenter';
import type { TeamMembersOutputPort } from '../ports/output/TeamMembersOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { AsyncUseCase } from '@core/shared/application';
@@ -11,7 +11,7 @@ import type { Logger } from '@core/shared/application';
/**
* Use Case for retrieving team members.
*/
export class GetTeamMembersUseCase implements AsyncUseCase<{ teamId: string }, TeamMembersResultDTO, 'REPOSITORY_ERROR'>
export class GetTeamMembersUseCase implements AsyncUseCase<{ teamId: string }, TeamMembersOutputPort, 'REPOSITORY_ERROR'>
{
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
@@ -20,7 +20,7 @@ export class GetTeamMembersUseCase implements AsyncUseCase<{ teamId: string }, T
private readonly logger: Logger,
) {}
async execute(input: { teamId: string }): Promise<Result<TeamMembersResultDTO, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
async execute(input: { teamId: string }): Promise<Result<TeamMembersOutputPort, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
this.logger.debug(`Executing GetTeamMembersUseCase for teamId: ${input.teamId}`);
try {
@@ -34,7 +34,7 @@ export class GetTeamMembersUseCase implements AsyncUseCase<{ teamId: string }, T
this.logger.debug(`Processing membership for driverId: ${membership.driverId}`);
const driver = await this.driverRepository.findById(membership.driverId);
if (driver) {
driverNames[membership.driverId] = driver.name;
driverNames[membership.driverId] = driver.name.value;
} else {
this.logger.warn(`Driver with ID ${membership.driverId} not found while fetching team members for team ${input.teamId}.`);
}
@@ -43,13 +43,24 @@ export class GetTeamMembersUseCase implements AsyncUseCase<{ teamId: string }, T
avatarUrls[membership.driverId] = avatarResult.avatarUrl;
}
const dto: TeamMembersResultDTO = {
memberships,
driverNames,
avatarUrls,
const members = memberships.map(membership => ({
driverId: membership.driverId,
driverName: driverNames[membership.driverId] || 'Unknown',
role: membership.role as 'owner' | 'manager' | 'member',
joinedAt: membership.joinedAt instanceof Date ? membership.joinedAt : new Date(membership.joinedAt),
isActive: membership.status === 'active',
avatarUrl: avatarUrls[membership.driverId] || '',
}));
const outputPort: TeamMembersOutputPort = {
members,
totalCount: memberships.length,
ownerCount: memberships.filter(m => m.role === 'owner').length,
managerCount: memberships.filter(m => m.role === 'manager').length,
memberCount: memberships.filter(m => m.role === 'member').length,
};
return Result.ok(dto);
return Result.ok(outputPort);
} catch (error) {
this.logger.error(`Error in GetTeamMembersUseCase for teamId: ${input.teamId}`, error as Error, { teamId: input.teamId });
return Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'Failed to retrieve team members' } });

View File

@@ -1,7 +1,7 @@
import type { ITeamRepository } from '@core/racing/domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '@core/racing/domain/repositories/ITeamMembershipRepository';
import type { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
import type { TeamsLeaderboardResultDTO } from '@core/racing/application/presenters/ITeamsLeaderboardPresenter';
import type { TeamsLeaderboardOutputPort, SkillLevel } from '../ports/output/TeamsLeaderboardOutputPort';
import { SkillLevelService } from '@core/racing/domain/services/SkillLevelService';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -30,7 +30,7 @@ interface TeamLeaderboardItem {
/**
* Use case: GetTeamsLeaderboardUseCase
*/
export class GetTeamsLeaderboardUseCase implements AsyncUseCase<void, TeamsLeaderboardResultDTO, 'REPOSITORY_ERROR'>
export class GetTeamsLeaderboardUseCase implements AsyncUseCase<void, TeamsLeaderboardOutputPort, 'REPOSITORY_ERROR'>
{
constructor(
private readonly teamRepository: ITeamRepository,
@@ -40,7 +40,7 @@ export class GetTeamsLeaderboardUseCase implements AsyncUseCase<void, TeamsLeade
private readonly logger: Logger,
) {}
async execute(): Promise<Result<TeamsLeaderboardResultDTO, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
async execute(): Promise<Result<TeamsLeaderboardOutputPort, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
try {
const allTeams = await this.teamRepository.findAll();
const teams: TeamLeaderboardItem[] = [];
@@ -88,12 +88,28 @@ export class GetTeamsLeaderboardUseCase implements AsyncUseCase<void, TeamsLeade
const recruitingCount = teams.filter((t) => t.isRecruiting).length;
const result: TeamsLeaderboardResultDTO = {
teams,
recruitingCount,
const groupsBySkillLevel: Record<SkillLevel, typeof teams> = {
beginner: [],
intermediate: [],
advanced: [],
pro: [],
};
return Result.ok(result);
teams.forEach(team => {
const level = team.performanceLevel as SkillLevel;
groupsBySkillLevel[level].push(team);
});
const topTeams = teams.slice(0, 10); // Assuming top 10
const outputPort: TeamsLeaderboardOutputPort = {
teams,
recruitingCount,
groupsBySkillLevel,
topTeams,
};
return Result.ok(outputPort);
} catch (error) {
this.logger.error('Error retrieving teams leaderboard', error as Error);
return Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'Failed to retrieve teams leaderboard' } });

View File

@@ -1,5 +1,5 @@
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { TotalDriversResultDTO } from '../presenters/ITotalDriversPresenter';
import type { TotalDriversOutputPort } from '../ports/output/TotalDriversOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { AsyncUseCase } from '@core/shared/application';
@@ -8,21 +8,21 @@ import type { Logger } from '@core/shared/application';
/**
* Use Case for retrieving total number of drivers.
*/
export class GetTotalDriversUseCase implements AsyncUseCase<void, TotalDriversResultDTO, 'REPOSITORY_ERROR'>
export class GetTotalDriversUseCase implements AsyncUseCase<void, TotalDriversOutputPort, 'REPOSITORY_ERROR'>
{
constructor(
private readonly driverRepository: IDriverRepository,
private readonly logger: Logger,
) {}
async execute(): Promise<Result<TotalDriversResultDTO, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
async execute(): Promise<Result<TotalDriversOutputPort, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
try {
const drivers = await this.driverRepository.findAll();
const dto: TotalDriversResultDTO = {
const output: TotalDriversOutputPort = {
totalDrivers: drivers.length,
};
return Result.ok(dto);
return Result.ok(output);
} catch (error) {
this.logger.error('Error retrieving total drivers', error as Error);
return Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'Failed to retrieve total drivers' } });

View File

@@ -1,23 +1,23 @@
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { GetTotalLeaguesResultDTO } from '../presenters/IGetTotalLeaguesPresenter';
import type { GetTotalLeaguesOutputPort } from '../ports/output/GetTotalLeaguesOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { AsyncUseCase } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
export class GetTotalLeaguesUseCase implements AsyncUseCase<void, GetTotalLeaguesResultDTO, 'REPOSITORY_ERROR'>
export class GetTotalLeaguesUseCase implements AsyncUseCase<void, GetTotalLeaguesOutputPort, 'REPOSITORY_ERROR'>
{
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly logger: Logger,
) {}
async execute(): Promise<Result<GetTotalLeaguesResultDTO, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
async execute(): Promise<Result<GetTotalLeaguesOutputPort, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
try {
const leagues = await this.leagueRepository.findAll();
const dto: GetTotalLeaguesResultDTO = { totalLeagues: leagues.length };
const output: GetTotalLeaguesOutputPort = { totalLeagues: leagues.length };
return Result.ok(dto);
return Result.ok(output);
} catch (error) {
this.logger.error('Error retrieving total leagues', error as Error);
return Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'Failed to retrieve total leagues' } });

View File

@@ -1,23 +1,23 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { GetTotalRacesResultDTO } from '../presenters/IGetTotalRacesPresenter';
import type { GetTotalRacesOutputPort } from '../ports/output/GetTotalRacesOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { AsyncUseCase } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
export class GetTotalRacesUseCase implements AsyncUseCase<void, GetTotalRacesResultDTO, 'REPOSITORY_ERROR'>
export class GetTotalRacesUseCase implements AsyncUseCase<void, GetTotalRacesOutputPort, 'REPOSITORY_ERROR'>
{
constructor(
private readonly raceRepository: IRaceRepository,
private readonly logger: Logger,
) {}
async execute(): Promise<Result<GetTotalRacesResultDTO, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
async execute(): Promise<Result<GetTotalRacesOutputPort, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
try {
const races = await this.raceRepository.findAll();
const dto: GetTotalRacesResultDTO = { totalRaces: races.length };
const output: GetTotalRacesOutputPort = { totalRaces: races.length };
return Result.ok(dto);
return Result.ok(output);
} catch (error) {
this.logger.error('Error retrieving total races', error as Error);
return Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'Failed to retrieve total races' } });

View File

@@ -7,7 +7,7 @@ import { Result } from '../../domain/entities/Result';
import type { AsyncUseCase, Logger } from '@core/shared/application';
import { Result as SharedResult } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { ImportRaceResultsApiResultDTO } from '../presenters/IImportRaceResultsApiPresenter';
import type { ImportRaceResultsApiOutputPort } from '../ports/output/ImportRaceResultsApiOutputPort';
export interface ImportRaceResultDTO {
id: string;
@@ -29,7 +29,7 @@ type ImportRaceResultsApiErrorCode =
type ImportRaceResultsApiApplicationError = ApplicationErrorCode<ImportRaceResultsApiErrorCode, { message: string }>;
export class ImportRaceResultsApiUseCase implements AsyncUseCase<{ raceId: string; resultsFileContent: string }, ImportRaceResultsApiResultDTO, ImportRaceResultsApiErrorCode> {
export class ImportRaceResultsApiUseCase implements AsyncUseCase<{ raceId: string; resultsFileContent: string }, ImportRaceResultsApiOutputPort, ImportRaceResultsApiErrorCode> {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
@@ -39,7 +39,7 @@ export class ImportRaceResultsApiUseCase implements AsyncUseCase<{ raceId: strin
private readonly logger: Logger,
) {}
async execute(params: { raceId: string; resultsFileContent: string }): Promise<SharedResult<ImportRaceResultsApiResultDTO, ApplicationErrorCode<ImportRaceResultsApiErrorCode>>> {
async execute(params: { raceId: string; resultsFileContent: string }): Promise<SharedResult<ImportRaceResultsApiOutputPort, ApplicationErrorCode<ImportRaceResultsApiErrorCode>>> {
this.logger.debug('ImportRaceResultsApiUseCase:execute', { raceId: params.raceId });
const { raceId, resultsFileContent } = params;
@@ -107,9 +107,10 @@ export class ImportRaceResultsApiUseCase implements AsyncUseCase<{ raceId: strin
await this.standingRepository.recalculate(league.id);
this.logger.info('ImportRaceResultsApiUseCase:standings recalculated', { leagueId: league.id });
const dto: ImportRaceResultsApiResultDTO = {
const output: ImportRaceResultsApiOutputPort = {
success: true,
raceId,
leagueId: league.id,
driversProcessed: results.length,
resultsRecorded: validEntities.length,
errors: [],

View File

@@ -3,6 +3,7 @@ import type { IsDriverRegisteredForRaceQueryParamsDTO } from '../dto/RaceRegistr
import type { AsyncUseCase, Logger } from '@core/shared/application';
import { Result as SharedResult } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { DriverRegistrationStatusOutputPort } from '../ports/output/DriverRegistrationStatusOutputPort';
type IsDriverRegisteredForRaceErrorCode = 'REPOSITORY_ERROR';
@@ -14,20 +15,20 @@ type IsDriverRegisteredForRaceApplicationError = ApplicationErrorCode<IsDriverRe
* Checks if a driver is registered for a specific race.
*/
export class IsDriverRegisteredForRaceUseCase
implements AsyncUseCase<IsDriverRegisteredForRaceQueryParamsDTO, boolean, IsDriverRegisteredForRaceErrorCode>
implements AsyncUseCase<IsDriverRegisteredForRaceQueryParamsDTO, DriverRegistrationStatusOutputPort, IsDriverRegisteredForRaceErrorCode>
{
constructor(
private readonly registrationRepository: IRaceRegistrationRepository,
private readonly logger: Logger,
) {}
async execute(params: IsDriverRegisteredForRaceQueryParamsDTO): Promise<SharedResult<boolean, IsDriverRegisteredForRaceApplicationError>> {
async execute(params: IsDriverRegisteredForRaceQueryParamsDTO): Promise<SharedResult<DriverRegistrationStatusOutputPort, IsDriverRegisteredForRaceApplicationError>> {
this.logger.debug('IsDriverRegisteredForRaceUseCase:execute', { params });
const { raceId, driverId } = params;
try {
const isRegistered = await this.registrationRepository.isRegistered(raceId, driverId);
return SharedResult.ok(isRegistered);
return SharedResult.ok({ isRegistered, raceId, driverId });
} catch (error) {
this.logger.error('IsDriverRegisteredForRaceUseCase:execution error', error instanceof Error ? error : new Error('Unknown error'));
return SharedResult.err({ code: 'REPOSITORY_ERROR', details: { message: error instanceof Error ? error.message : 'Unknown error' } });

View File

@@ -50,12 +50,9 @@ describe('JoinLeagueUseCase', () => {
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({
id: 'membership-1',
membershipId: 'membership-1',
leagueId: 'league-1',
driverId: 'driver-1',
role: 'member',
status: 'active',
joinedAt: expect.any(Date),
});
});
@@ -76,7 +73,6 @@ describe('JoinLeagueUseCase', () => {
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'ALREADY_MEMBER',
details: { message: 'Already a member or have a pending request' },
});
});
@@ -91,7 +87,6 @@ describe('JoinLeagueUseCase', () => {
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'REPOSITORY_ERROR',
details: { message: 'Repository error' },
});
});
});

View File

@@ -4,14 +4,13 @@ import { LeagueMembership, type MembershipRole, type MembershipStatus } from '..
import type { AsyncUseCase } from '@core/shared/application';
import { Result as SharedResult } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { JoinLeagueOutputPort } from '../ports/output/JoinLeagueOutputPort';
import type { JoinLeagueCommandDTO } from '../dto/JoinLeagueCommandDTO';
type JoinLeagueErrorCode = 'ALREADY_MEMBER' | 'REPOSITORY_ERROR';
type JoinLeagueApplicationError = ApplicationErrorCode<JoinLeagueErrorCode, { message: string }>;
export class JoinLeagueUseCase implements AsyncUseCase<JoinLeagueCommandDTO, LeagueMembership, JoinLeagueErrorCode> {
export class JoinLeagueUseCase implements AsyncUseCase<JoinLeagueCommandDTO, JoinLeagueOutputPort, JoinLeagueErrorCode> {
constructor(
private readonly membershipRepository: ILeagueMembershipRepository,
private readonly logger: Logger,
@@ -24,7 +23,7 @@ export class JoinLeagueUseCase implements AsyncUseCase<JoinLeagueCommandDTO, Lea
* - Returns error when membership already exists for this league/driver.
* - Creates a new active membership with role "member" and current timestamp.
*/
async execute(command: JoinLeagueCommandDTO): Promise<SharedResult<LeagueMembership, JoinLeagueApplicationError>> {
async execute(command: JoinLeagueCommandDTO): Promise<SharedResult<JoinLeagueOutputPort, ApplicationErrorCode<JoinLeagueErrorCode>>> {
this.logger.debug('Attempting to join league', { command });
const { leagueId, driverId } = command;
@@ -32,7 +31,7 @@ export class JoinLeagueUseCase implements AsyncUseCase<JoinLeagueCommandDTO, Lea
const existing = await this.membershipRepository.getMembership(leagueId, driverId);
if (existing) {
this.logger.warn('Driver already a member or has pending request', { leagueId, driverId });
return SharedResult.err({ code: 'ALREADY_MEMBER', details: { message: 'Already a member or have a pending request' } });
return SharedResult.err({ code: 'ALREADY_MEMBER' });
}
const membership = LeagueMembership.create({
@@ -44,10 +43,14 @@ export class JoinLeagueUseCase implements AsyncUseCase<JoinLeagueCommandDTO, Lea
const savedMembership = await this.membershipRepository.saveMembership(membership);
this.logger.info('Successfully joined league', { membershipId: savedMembership.id });
return SharedResult.ok(savedMembership);
return SharedResult.ok({
membershipId: savedMembership.id,
leagueId: savedMembership.leagueId.toString(),
status: savedMembership.status.toString(),
});
} catch (error) {
this.logger.error('Failed to join league due to an unexpected error', error instanceof Error ? error : new Error('Unknown error'));
return SharedResult.err({ code: 'REPOSITORY_ERROR', details: { message: error instanceof Error ? error.message : 'Unknown error' } });
return SharedResult.err({ code: 'REPOSITORY_ERROR' });
}
}
}

View File

@@ -1,35 +1,60 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { describe, it, expect, beforeEach } from 'vitest';
import { ListLeagueScoringPresetsUseCase } from './ListLeagueScoringPresetsUseCase';
import { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider';
describe('ListLeagueScoringPresetsUseCase', () => {
let useCase: ListLeagueScoringPresetsUseCase;
let presetProvider: {
listPresets: Mock;
};
beforeEach(() => {
presetProvider = {
listPresets: vi.fn(),
};
useCase = new ListLeagueScoringPresetsUseCase(
presetProvider as unknown as LeagueScoringPresetProvider,
);
const mockPresets = [
{
id: 'preset-1',
name: 'Preset 1',
description: 'Desc 1',
primaryChampionshipType: 'driver' as const,
dropPolicySummary: 'Drop 1',
sessionSummary: 'Session 1',
bonusSummary: 'Bonus 1',
createConfig: vi.fn(),
},
{
id: 'preset-2',
name: 'Preset 2',
description: 'Desc 2',
primaryChampionshipType: 'team' as const,
dropPolicySummary: 'Drop 2',
sessionSummary: 'Session 2',
bonusSummary: 'Bonus 2',
createConfig: vi.fn(),
},
];
useCase = new ListLeagueScoringPresetsUseCase(mockPresets);
});
it('should list presets successfully', async () => {
const mockPresets = [
{ id: 'preset-1', name: 'Preset 1' },
{ id: 'preset-2', name: 'Preset 2' },
];
presetProvider.listPresets.mockResolvedValue(mockPresets);
const result = await useCase.execute();
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({
presets: mockPresets,
presets: [
{
id: 'preset-1',
name: 'Preset 1',
description: 'Desc 1',
primaryChampionshipType: 'driver',
sessionSummary: 'Session 1',
bonusSummary: 'Bonus 1',
dropPolicySummary: 'Drop 1',
},
{
id: 'preset-2',
name: 'Preset 2',
description: 'Desc 2',
primaryChampionshipType: 'team',
sessionSummary: 'Session 2',
bonusSummary: 'Bonus 2',
dropPolicySummary: 'Drop 2',
},
],
});
});
});

View File

@@ -1,5 +1,6 @@
import type { LeagueScoringPresetOutputPort } from '../ports/output/LeagueScoringPresetOutputPort';
import type { LeagueScoringPresetsResultDTO } from '../presenters/ILeagueScoringPresetsPresenter';
import type { LeagueScoringPresetsOutputPort } from '../ports/output/LeagueScoringPresetsOutputPort';
import type { LeagueScoringPreset } from '../../../bootstrap/LeagueScoringPresets';
import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -9,15 +10,23 @@ import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorC
* Returns preset data without business logic.
*/
export class ListLeagueScoringPresetsUseCase
implements AsyncUseCase<void, LeagueScoringPresetsResultDTO, 'NO_ERROR'>
implements AsyncUseCase<void, LeagueScoringPresetsOutputPort, 'NO_ERROR'>
{
constructor(private readonly presets: LeagueScoringPresetOutputPort[]) {}
constructor(private readonly presets: LeagueScoringPreset[]) {}
async execute(): Promise<Result<LeagueScoringPresetsResultDTO, ApplicationErrorCode<'NO_ERROR'>>> {
const dto: LeagueScoringPresetsResultDTO = {
presets: this.presets,
async execute(): Promise<Result<LeagueScoringPresetsOutputPort, ApplicationErrorCode<'NO_ERROR'>>> {
const output: LeagueScoringPresetsOutputPort = {
presets: this.presets.map(p => ({
id: p.id,
name: p.name,
description: p.description,
primaryChampionshipType: p.primaryChampionshipType,
sessionSummary: p.sessionSummary,
bonusSummary: p.bonusSummary,
dropPolicySummary: p.dropPolicySummary,
} as LeagueScoringPresetOutputPort)),
};
return Result.ok(dto);
return Result.ok(output);
}
}

View File

@@ -1,6 +1,7 @@
import { SeasonScheduleGenerator } from '../../domain/services/SeasonScheduleGenerator';
import type { LeagueSchedulePreviewDTO, LeagueScheduleDTO } from '../dto/LeagueScheduleDTO';
import type { LeagueScheduleDTO } from '../dto/LeagueScheduleDTO';
import { scheduleDTOToSeasonSchedule } from '../dto/LeagueScheduleDTO';
import type { LeagueSchedulePreviewOutputPort } from '../ports/output/LeagueSchedulePreviewOutputPort';
import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -16,13 +17,13 @@ type PreviewLeagueScheduleErrorCode = 'INVALID_SCHEDULE';
type PreviewLeagueScheduleApplicationError = ApplicationErrorCode<PreviewLeagueScheduleErrorCode, { message: string }>;
export class PreviewLeagueScheduleUseCase implements AsyncUseCase<PreviewLeagueScheduleQueryParams, LeagueSchedulePreviewDTO, PreviewLeagueScheduleErrorCode> {
export class PreviewLeagueScheduleUseCase implements AsyncUseCase<PreviewLeagueScheduleQueryParams, LeagueSchedulePreviewOutputPort, PreviewLeagueScheduleErrorCode> {
constructor(
private readonly scheduleGenerator: typeof SeasonScheduleGenerator = SeasonScheduleGenerator,
private readonly logger: Logger,
) {}
async execute(params: PreviewLeagueScheduleQueryParams): Promise<Result<LeagueSchedulePreviewDTO, PreviewLeagueScheduleApplicationError>> {
async execute(params: PreviewLeagueScheduleQueryParams): Promise<Result<LeagueSchedulePreviewOutputPort, PreviewLeagueScheduleApplicationError>> {
this.logger.debug('Previewing league schedule', { params });
let seasonSchedule: SeasonSchedule;
@@ -48,7 +49,7 @@ export class PreviewLeagueScheduleUseCase implements AsyncUseCase<PreviewLeagueS
const summary = this.buildSummary(params.schedule, rounds);
const result: LeagueSchedulePreviewDTO = {
const result: LeagueSchedulePreviewOutputPort = {
rounds,
summary,
};

View File

@@ -2,22 +2,18 @@ import type { ILeagueMembershipRepository } from '../../domain/repositories/ILea
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { AsyncUseCase } from '@core/shared/application';
import type { RejectLeagueJoinRequestOutputPort } from '../ports/output/RejectLeagueJoinRequestOutputPort';
export interface RejectLeagueJoinRequestUseCaseParams {
requestId: string;
}
export interface RejectLeagueJoinRequestResultDTO {
success: boolean;
message: string;
}
export class RejectLeagueJoinRequestUseCase implements AsyncUseCase<RejectLeagueJoinRequestUseCaseParams, RejectLeagueJoinRequestResultDTO, 'NO_ERROR'> {
export class RejectLeagueJoinRequestUseCase implements AsyncUseCase<RejectLeagueJoinRequestUseCaseParams, RejectLeagueJoinRequestOutputPort, 'NO_ERROR'> {
constructor(private readonly leagueMembershipRepository: ILeagueMembershipRepository) {}
async execute(params: RejectLeagueJoinRequestUseCaseParams): Promise<Result<RejectLeagueJoinRequestResultDTO, ApplicationErrorCode<'NO_ERROR'>>> {
async execute(params: RejectLeagueJoinRequestUseCaseParams): Promise<Result<RejectLeagueJoinRequestOutputPort, ApplicationErrorCode<'NO_ERROR'>>> {
await this.leagueMembershipRepository.removeJoinRequest(params.requestId);
const dto: RejectLeagueJoinRequestResultDTO = { success: true, message: 'Join request rejected.' };
return Result.ok(dto);
const port: RejectLeagueJoinRequestOutputPort = { success: true, message: 'Join request rejected.' };
return Result.ok(port);
}
}

View File

@@ -1,20 +1,17 @@
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { RemoveLeagueMemberOutputPort } from '../ports/output/RemoveLeagueMemberOutputPort';
export interface RemoveLeagueMemberUseCaseParams {
leagueId: string;
targetDriverId: string;
}
export interface RemoveLeagueMemberResultDTO {
success: boolean;
}
export class RemoveLeagueMemberUseCase {
constructor(private readonly leagueMembershipRepository: ILeagueMembershipRepository) {}
async execute(params: RemoveLeagueMemberUseCaseParams): Promise<Result<RemoveLeagueMemberResultDTO, ApplicationErrorCode<'MEMBERSHIP_NOT_FOUND'>>> {
async execute(params: RemoveLeagueMemberUseCaseParams): Promise<Result<RemoveLeagueMemberOutputPort, ApplicationErrorCode<'MEMBERSHIP_NOT_FOUND'>>> {
const memberships = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId);
const membership = memberships.find(m => m.driverId === params.targetDriverId);
if (!membership) {

View File

@@ -7,7 +7,7 @@ import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeague
import type {
MembershipRole,
} from '@core/racing/domain/entities/LeagueMembership';
import type { TransferLeagueOwnershipResultDTO } from '../presenters/ITransferLeagueOwnershipPresenter';
import type { TransferLeagueOwnershipOutputPort } from '../ports/output/TransferLeagueOwnershipOutputPort';
export interface TransferLeagueOwnershipCommandDTO {
leagueId: string;
@@ -23,7 +23,7 @@ export class TransferLeagueOwnershipUseCase {
private readonly membershipRepository: ILeagueMembershipRepository
) {}
async execute(command: TransferLeagueOwnershipCommandDTO): Promise<Result<void, ApplicationErrorCode<TransferLeagueOwnershipErrorCode>>> {
async execute(command: TransferLeagueOwnershipCommandDTO): Promise<Result<TransferLeagueOwnershipOutputPort, ApplicationErrorCode<TransferLeagueOwnershipErrorCode>>> {
const { leagueId, currentOwnerId, newOwnerId } = command;
const league = await this.leagueRepository.findById(leagueId);
@@ -57,6 +57,6 @@ export class TransferLeagueOwnershipUseCase {
const updatedLeague = league.update({ ownerId: newOwnerId });
await this.leagueRepository.update(updatedLeague);
return Result.ok(undefined);
return Result.ok({ success: true });
}
}

View File

@@ -1,7 +1,7 @@
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { UpdateLeagueMemberRoleResultDTO } from '../presenters/IUpdateLeagueMemberRolePresenter';
import type { UpdateLeagueMemberRoleOutputPort } from '../ports/output/UpdateLeagueMemberRoleOutputPort';
export interface UpdateLeagueMemberRoleUseCaseParams {
leagueId: string;
@@ -12,7 +12,7 @@ export interface UpdateLeagueMemberRoleUseCaseParams {
export class UpdateLeagueMemberRoleUseCase {
constructor(private readonly leagueMembershipRepository: ILeagueMembershipRepository) {}
async execute(params: UpdateLeagueMemberRoleUseCaseParams): Promise<Result<UpdateLeagueMemberRoleResultDTO, ApplicationErrorCode<'MEMBERSHIP_NOT_FOUND'>>> {
async execute(params: UpdateLeagueMemberRoleUseCaseParams): Promise<Result<UpdateLeagueMemberRoleOutputPort, ApplicationErrorCode<'MEMBERSHIP_NOT_FOUND'>>> {
const memberships = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId);
const membership = memberships.find(m => m.driverId === params.targetDriverId);
if (!membership) {