This commit is contained in:
2025-12-10 18:28:32 +01:00
parent 6d61be9c51
commit 1303a14493
108 changed files with 3366 additions and 1559 deletions

View File

@@ -0,0 +1,47 @@
import type { League } from '../../domain/entities/League';
import type { Season } from '../../domain/entities/Season';
import type { LeagueScoringConfig } from '../../domain/entities/LeagueScoringConfig';
import type { Game } from '../../domain/entities/Game';
import type { LeagueScoringPresetDTO } from '../ports/LeagueScoringPresetProvider';
export interface LeagueSummaryViewModel {
id: string;
name: string;
description: string;
ownerId: string;
createdAt: Date;
maxDrivers: number;
usedDriverSlots: number;
maxTeams?: number;
usedTeamSlots?: number;
structureSummary: string;
scoringPatternSummary?: string;
timingSummary: string;
scoring?: {
gameId: string;
gameName: string;
primaryChampionshipType: string;
scoringPresetId: string;
scoringPresetName: string;
dropPolicySummary: string;
scoringPatternSummary: string;
};
}
export interface AllLeaguesWithCapacityAndScoringViewModel {
leagues: LeagueSummaryViewModel[];
totalCount: number;
}
export interface LeagueEnrichedData {
league: League;
usedDriverSlots: number;
season?: Season;
scoringConfig?: LeagueScoringConfig;
game?: Game;
preset?: LeagueScoringPresetDTO;
}
export interface IAllLeaguesWithCapacityAndScoringPresenter {
present(enrichedLeagues: LeagueEnrichedData[]): AllLeaguesWithCapacityAndScoringViewModel;
}

View File

@@ -0,0 +1,32 @@
import type { League } from '../../domain/entities/League';
export interface LeagueWithCapacityViewModel {
id: string;
name: string;
description: string;
ownerId: string;
settings: {
maxDrivers: number;
sessionDuration?: number;
visibility?: string;
};
createdAt: string;
socialLinks?: {
discordUrl?: string;
youtubeUrl?: string;
websiteUrl?: string;
};
usedSlots: number;
}
export interface AllLeaguesWithCapacityViewModel {
leagues: LeagueWithCapacityViewModel[];
totalCount: number;
}
export interface IAllLeaguesWithCapacityPresenter {
present(
leagues: League[],
memberCounts: Map<string, number>
): AllLeaguesWithCapacityViewModel;
}

View File

@@ -0,0 +1,22 @@
import type { Team } from '../../domain/entities/Team';
export interface TeamListItemViewModel {
id: string;
name: string;
tag: string;
description: string;
memberCount: number;
leagues: string[];
specialization?: 'endurance' | 'sprint' | 'mixed';
region?: string;
languages?: string[];
}
export interface AllTeamsViewModel {
teams: TeamListItemViewModel[];
totalCount: number;
}
export interface IAllTeamsPresenter {
present(teams: Team[]): AllTeamsViewModel;
}

View File

@@ -0,0 +1,14 @@
export interface DriverRegistrationStatusViewModel {
isRegistered: boolean;
raceId: string;
driverId: string;
}
export interface IDriverRegistrationStatusPresenter {
present(
isRegistered: boolean,
raceId: string,
driverId: string
): DriverRegistrationStatusViewModel;
getViewModel(): DriverRegistrationStatusViewModel;
}

View File

@@ -0,0 +1,30 @@
import type { Team, TeamMembership } from '../../domain/entities/Team';
export interface DriverTeamViewModel {
team: {
id: string;
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
specialization?: 'endurance' | 'sprint' | 'mixed';
region?: string;
languages?: string[];
};
membership: {
role: 'owner' | 'manager' | 'member';
joinedAt: string;
isActive: boolean;
};
isOwner: boolean;
canManage: boolean;
}
export interface IDriverTeamPresenter {
present(
team: Team,
membership: TeamMembership,
driverId: string
): DriverTeamViewModel;
}

View File

@@ -0,0 +1,34 @@
import type { Driver } from '../../domain/entities/Driver';
import type { SkillLevel } from '../../domain/services/SkillLevelService';
export type { SkillLevel };
export interface DriverLeaderboardItemViewModel {
id: string;
name: string;
rating: number;
skillLevel: SkillLevel;
nationality: string;
racesCompleted: number;
wins: number;
podiums: number;
isActive: boolean;
rank: number;
avatarUrl: string;
}
export interface DriversLeaderboardViewModel {
drivers: DriverLeaderboardItemViewModel[];
totalRaces: number;
totalWins: number;
activeCount: number;
}
export interface IDriversLeaderboardPresenter {
present(
drivers: Driver[],
rankings: Array<{ driverId: string; rating: number; overallRank: number }>,
stats: Record<string, { rating: number; wins: number; podiums: number; totalRaces: number; overallRank: number }>,
avatarUrls: Record<string, string>
): DriversLeaderboardViewModel;
}

View File

@@ -0,0 +1,5 @@
import type { GetEntitySponsorshipPricingResultDTO } from '../use-cases/GetEntitySponsorshipPricingQuery';
export interface IEntitySponsorshipPricingPresenter {
present(data: GetEntitySponsorshipPricingResultDTO | null): void;
}

View File

@@ -0,0 +1,40 @@
export interface LeagueDriverSeasonStatsItemViewModel {
leagueId: string;
driverId: string;
position: number;
driverName: string;
teamId?: string;
teamName?: string;
totalPoints: number;
basePoints: number;
penaltyPoints: number;
bonusPoints: number;
pointsPerRace: number;
racesStarted: number;
racesFinished: number;
dnfs: number;
noShows: number;
avgFinish: number | null;
rating: number | null;
ratingChange: number | null;
}
export interface LeagueDriverSeasonStatsViewModel {
leagueId: string;
stats: LeagueDriverSeasonStatsItemViewModel[];
}
export interface ILeagueDriverSeasonStatsPresenter {
present(
leagueId: string,
standings: Array<{
driverId: string;
position: number;
points: number;
racesCompleted: number;
}>,
penalties: Map<string, { baseDelta: number; bonusDelta: number }>,
driverResults: Map<string, Array<{ position: number }>>,
driverRatings: Map<string, { rating: number | null; ratingChange: number | null }>
): LeagueDriverSeasonStatsViewModel;
}

View File

@@ -0,0 +1,64 @@
import type { League } from '../../domain/entities/League';
import type { Season } from '../../domain/entities/Season';
import type { LeagueScoringConfig } from '../../domain/entities/LeagueScoringConfig';
import type { Game } from '../../domain/entities/Game';
export interface LeagueConfigFormViewModel {
leagueId: string;
basics: {
name: string;
description: string;
visibility: string;
gameId: string;
};
structure: {
mode: string;
maxDrivers: number;
maxTeams?: number;
driversPerTeam?: number;
multiClassEnabled: boolean;
};
championships: {
enableDriverChampionship: boolean;
enableTeamChampionship: boolean;
enableNationsChampionship: boolean;
enableTrophyChampionship: boolean;
};
scoring: {
patternId?: string;
customScoringEnabled: boolean;
};
dropPolicy: {
strategy: string;
n?: number;
};
timings: {
practiceMinutes: number;
qualifyingMinutes: number;
sprintRaceMinutes?: number;
mainRaceMinutes: number;
sessionCount: number;
roundsPlanned: number;
};
stewarding: {
decisionMode: string;
requireDefense: boolean;
defenseTimeLimit: number;
voteTimeLimit: number;
protestDeadlineHours: number;
stewardingClosesHours: number;
notifyAccusedOnProtest: boolean;
notifyOnVoteRequired: boolean;
};
}
export interface LeagueFullConfigData {
league: League;
activeSeason?: Season;
scoringConfig?: LeagueScoringConfig;
game?: Game;
}
export interface ILeagueFullConfigPresenter {
present(data: LeagueFullConfigData): LeagueConfigFormViewModel;
}

View File

@@ -0,0 +1,5 @@
import type { LeagueSchedulePreviewDTO } from '../dto/LeagueScheduleDTO';
export interface ILeagueSchedulePreviewPresenter {
present(data: LeagueSchedulePreviewDTO): void;
}

View File

@@ -0,0 +1,38 @@
import type { ChampionshipConfig } from '../../domain/value-objects/ChampionshipConfig';
import type { LeagueScoringPresetDTO } from '../ports/LeagueScoringPresetProvider';
export interface LeagueScoringChampionshipViewModel {
id: string;
name: string;
type: string;
sessionTypes: string[];
pointsPreview: Array<{ sessionType: string; position: number; points: number }>;
bonusSummary: string[];
dropPolicyDescription: string;
}
export interface LeagueScoringConfigViewModel {
leagueId: string;
seasonId: string;
gameId: string;
gameName: string;
scoringPresetId?: string;
scoringPresetName?: string;
dropPolicySummary: string;
championships: LeagueScoringChampionshipViewModel[];
}
export interface LeagueScoringConfigData {
leagueId: string;
seasonId: string;
gameId: string;
gameName: string;
scoringPresetId?: string;
preset?: LeagueScoringPresetDTO;
championships: ChampionshipConfig[];
}
export interface ILeagueScoringConfigPresenter {
present(data: LeagueScoringConfigData): LeagueScoringConfigViewModel;
getViewModel(): LeagueScoringConfigViewModel;
}

View File

@@ -0,0 +1,10 @@
import type { LeagueScoringPresetDTO } from '../ports/LeagueScoringPresetProvider';
export interface LeagueScoringPresetsViewModel {
presets: LeagueScoringPresetDTO[];
totalCount: number;
}
export interface ILeagueScoringPresetsPresenter {
present(presets: LeagueScoringPresetDTO[]): LeagueScoringPresetsViewModel;
}

View File

@@ -0,0 +1,22 @@
import type { Standing } from '../../domain/entities/Standing';
export interface StandingItemViewModel {
id: string;
leagueId: string;
seasonId: string;
driverId: string;
position: number;
points: number;
wins: number;
podiums: number;
racesCompleted: number;
}
export interface LeagueStandingsViewModel {
leagueId: string;
standings: StandingItemViewModel[];
}
export interface ILeagueStandingsPresenter {
present(standings: Standing[]): LeagueStandingsViewModel;
}

View File

@@ -0,0 +1,20 @@
export interface LeagueStatsViewModel {
leagueId: string;
totalRaces: number;
completedRaces: number;
scheduledRaces: number;
averageSOF: number | null;
highestSOF: number | null;
lowestSOF: number | null;
}
export interface ILeagueStatsPresenter {
present(
leagueId: string,
totalRaces: number,
completedRaces: number,
scheduledRaces: number,
sofValues: number[]
): LeagueStatsViewModel;
getViewModel(): LeagueStatsViewModel;
}

View File

@@ -0,0 +1,5 @@
import type { GetPendingSponsorshipRequestsResultDTO } from '../use-cases/GetPendingSponsorshipRequestsQuery';
export interface IPendingSponsorshipRequestsPresenter {
present(data: GetPendingSponsorshipRequestsResultDTO): void;
}

View File

@@ -0,0 +1,44 @@
import type { PenaltyType, PenaltyStatus } from '../../domain/entities/Penalty';
export interface RacePenaltyViewModel {
id: string;
raceId: string;
driverId: string;
driverName: string;
type: PenaltyType;
value?: number;
reason: string;
protestId?: string;
issuedBy: string;
issuedByName: string;
status: PenaltyStatus;
description: string;
issuedAt: string;
appliedAt?: string;
notes?: string;
}
export interface RacePenaltiesViewModel {
penalties: RacePenaltyViewModel[];
}
export interface IRacePenaltiesPresenter {
present(
penalties: Array<{
id: string;
raceId: string;
driverId: string;
type: PenaltyType;
value?: number;
reason: string;
protestId?: string;
issuedBy: string;
status: PenaltyStatus;
issuedAt: Date;
appliedAt?: Date;
notes?: string;
getDescription(): string;
}>,
driverMap: Map<string, string>
): RacePenaltiesViewModel;
}

View File

@@ -0,0 +1,43 @@
import type { ProtestStatus, ProtestIncident } from '../../domain/entities/Protest';
export interface RaceProtestViewModel {
id: string;
raceId: string;
protestingDriverId: string;
protestingDriverName: string;
accusedDriverId: string;
accusedDriverName: string;
incident: ProtestIncident;
comment?: string;
proofVideoUrl?: string;
status: ProtestStatus;
reviewedBy?: string;
reviewedByName?: string;
decisionNotes?: string;
filedAt: string;
reviewedAt?: string;
}
export interface RaceProtestsViewModel {
protests: RaceProtestViewModel[];
}
export interface IRaceProtestsPresenter {
present(
protests: Array<{
id: string;
raceId: string;
protestingDriverId: string;
accusedDriverId: string;
incident: ProtestIncident;
comment?: string;
proofVideoUrl?: string;
status: ProtestStatus;
reviewedBy?: string;
decisionNotes?: string;
filedAt: Date;
reviewedAt?: Date;
}>,
driverMap: Map<string, string>
): RaceProtestsViewModel;
}

View File

@@ -0,0 +1,9 @@
export interface RaceRegistrationsViewModel {
registeredDriverIds: string[];
count: number;
}
export interface IRaceRegistrationsPresenter {
present(registeredDriverIds: string[]): RaceRegistrationsViewModel;
getViewModel(): RaceRegistrationsViewModel;
}

View File

@@ -0,0 +1,34 @@
export interface RaceWithSOFViewModel {
id: string;
leagueId: string;
scheduledAt: string;
track: string;
trackId: string;
car: string;
carId: string;
sessionType: string;
status: string;
strengthOfField: number | null;
registeredCount: number;
maxParticipants: number;
participantCount: number;
}
export interface IRaceWithSOFPresenter {
present(
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
): RaceWithSOFViewModel;
getViewModel(): RaceWithSOFViewModel;
}

View File

@@ -0,0 +1,31 @@
export interface RaceListItemViewModel {
id: string;
track: string;
car: string;
scheduledAt: string;
status: 'scheduled' | 'running' | 'completed' | 'cancelled';
leagueId: string;
leagueName: string;
strengthOfField: number | null;
isUpcoming: boolean;
isLive: boolean;
isPast: boolean;
}
export interface RacesPageViewModel {
races: RaceListItemViewModel[];
stats: {
total: number;
scheduled: number;
running: number;
completed: number;
};
liveRaces: RaceListItemViewModel[];
upcomingThisWeek: RaceListItemViewModel[];
recentResults: RaceListItemViewModel[];
}
export interface IRacesPagePresenter {
present(races: any[]): void;
getViewModel(): RacesPageViewModel;
}

View File

@@ -0,0 +1,5 @@
import type { SponsoredLeagueDTO, SponsorDashboardDTO } from '../use-cases/GetSponsorDashboardQuery';
export interface ISponsorDashboardPresenter {
present(data: SponsorDashboardDTO | null): void;
}

View File

@@ -0,0 +1,5 @@
import type { SponsorSponsorshipsDTO } from '../use-cases/GetSponsorSponsorshipsQuery';
export interface ISponsorSponsorshipsPresenter {
present(data: SponsorSponsorshipsDTO | null): void;
}

View File

@@ -0,0 +1,29 @@
import type { Team, TeamMembership } from '../../domain/entities/Team';
export interface TeamDetailsViewModel {
team: {
id: string;
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
specialization?: 'endurance' | 'sprint' | 'mixed';
region?: string;
languages?: string[];
};
membership: {
role: 'owner' | 'manager' | 'member';
joinedAt: string;
isActive: boolean;
} | null;
canManage: boolean;
}
export interface ITeamDetailsPresenter {
present(
team: Team,
membership: TeamMembership | null,
driverId: string
): TeamDetailsViewModel;
}

View File

@@ -0,0 +1,25 @@
import type { TeamJoinRequest } from '../../domain/entities/Team';
export interface TeamJoinRequestViewModel {
requestId: string;
driverId: string;
driverName: string;
teamId: string;
status: 'pending' | 'approved' | 'rejected';
requestedAt: string;
avatarUrl: string;
}
export interface TeamJoinRequestsViewModel {
requests: TeamJoinRequestViewModel[];
pendingCount: number;
totalCount: number;
}
export interface ITeamJoinRequestsPresenter {
present(
requests: TeamJoinRequest[],
driverNames: Record<string, string>,
avatarUrls: Record<string, string>
): TeamJoinRequestsViewModel;
}

View File

@@ -0,0 +1,26 @@
import type { TeamMembership } from '../../domain/entities/Team';
export interface TeamMemberViewModel {
driverId: string;
driverName: string;
role: 'owner' | 'manager' | 'member';
joinedAt: string;
isActive: boolean;
avatarUrl: string;
}
export interface TeamMembersViewModel {
members: TeamMemberViewModel[];
totalCount: number;
ownerCount: number;
managerCount: number;
memberCount: number;
}
export interface ITeamMembersPresenter {
present(
memberships: TeamMembership[],
driverNames: Record<string, string>,
avatarUrls: Record<string, string>
): TeamMembersViewModel;
}

View File

@@ -0,0 +1,27 @@
export type SkillLevel = 'beginner' | 'intermediate' | 'advanced' | 'pro';
export interface TeamLeaderboardItemViewModel {
id: string;
name: string;
memberCount: number;
rating: number | null;
totalWins: number;
totalRaces: number;
performanceLevel: SkillLevel;
isRecruiting: boolean;
createdAt: Date;
description?: string;
specialization?: 'endurance' | 'sprint' | 'mixed';
region?: string;
languages?: string[];
}
export interface TeamsLeaderboardViewModel {
teams: TeamLeaderboardItemViewModel[];
recruitingCount: number;
}
export interface ITeamsLeaderboardPresenter {
present(teams: any[], recruitingCount: number): void;
getViewModel(): TeamsLeaderboardViewModel;
}

View File

@@ -3,23 +3,14 @@ import type { ILeagueMembershipRepository } from '../../domain/repositories/ILea
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
import type {
LeagueScoringPresetProvider,
LeagueScoringPresetDTO,
} from '../ports/LeagueScoringPresetProvider';
import type {
LeagueSummaryDTO,
LeagueSummaryScoringDTO,
} from '../dto/LeagueSummaryDTO';
import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider';
import type { IAllLeaguesWithCapacityAndScoringPresenter, LeagueEnrichedData } from '../presenters/IAllLeaguesWithCapacityAndScoringPresenter';
/**
* Combined capacity + scoring summary query for leagues.
*
* Extends the behavior of GetAllLeaguesWithCapacityQuery by including
* scoring preset and game summaries when an active season and
* LeagueScoringConfig are available.
* Use Case for retrieving all leagues with capacity and scoring information.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetAllLeaguesWithCapacityAndScoringQuery {
export class GetAllLeaguesWithCapacityAndScoringUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
@@ -27,17 +18,16 @@ export class GetAllLeaguesWithCapacityAndScoringQuery {
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
private readonly gameRepository: IGameRepository,
private readonly presetProvider: LeagueScoringPresetProvider,
public readonly presenter: IAllLeaguesWithCapacityAndScoringPresenter,
) {}
async execute(): Promise<LeagueSummaryDTO[]> {
async execute(): Promise<void> {
const leagues = await this.leagueRepository.findAll();
const results: LeagueSummaryDTO[] = [];
const enrichedLeagues: LeagueEnrichedData[] = [];
for (const league of leagues) {
const members = await this.leagueMembershipRepository.getLeagueMembers(
league.id,
);
const members = await this.leagueMembershipRepository.getLeagueMembers(league.id);
const usedDriverSlots = members.filter(
(m) =>
@@ -48,116 +38,36 @@ export class GetAllLeaguesWithCapacityAndScoringQuery {
m.role === 'member'),
).length;
const configuredMaxDrivers = league.settings.maxDrivers ?? usedDriverSlots;
const safeMaxDrivers = Math.max(configuredMaxDrivers, usedDriverSlots);
const seasons = await this.seasonRepository.findByLeagueId(league.id);
const activeSeason = seasons && seasons.length > 0
? seasons.find((s) => s.status === 'active') ?? seasons[0]
: undefined;
const scoringSummary = await this.buildScoringSummary(league.id);
let scoringConfig;
let game;
let preset;
const structureSummary = `Solo • ${safeMaxDrivers} drivers`;
if (activeSeason) {
scoringConfig = await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
if (scoringConfig) {
game = await this.gameRepository.findById(activeSeason.gameId);
const presetId = scoringConfig.scoringPresetId;
if (presetId) {
preset = this.presetProvider.getPresetById(presetId);
}
}
}
const qualifyingMinutes = 30;
const mainRaceMinutes =
typeof league.settings.sessionDuration === 'number'
? league.settings.sessionDuration
: 40;
const timingSummary = `${qualifyingMinutes} min Quali • ${mainRaceMinutes} min Race`;
const dto: LeagueSummaryDTO = {
id: league.id,
name: league.name,
description: league.description,
ownerId: league.ownerId,
createdAt: league.createdAt,
maxDrivers: safeMaxDrivers,
enrichedLeagues.push({
league,
usedDriverSlots,
maxTeams: undefined,
usedTeamSlots: undefined,
structureSummary,
scoringPatternSummary: scoringSummary?.scoringPatternSummary,
timingSummary,
scoring: scoringSummary,
};
results.push(dto);
season: activeSeason,
scoringConfig,
game,
preset,
});
}
return results;
}
private async buildScoringSummary(
leagueId: string,
): Promise<LeagueSummaryScoringDTO | undefined> {
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
if (!seasons || seasons.length === 0) {
return undefined;
}
const activeSeason =
seasons.find((s) => s.status === 'active') ?? seasons[0];
const scoringConfig =
await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
if (!scoringConfig) {
return undefined;
}
const game = await this.gameRepository.findById(activeSeason.gameId);
if (!game) {
return undefined;
}
const presetId = scoringConfig.scoringPresetId;
let preset: LeagueScoringPresetDTO | undefined;
if (presetId) {
preset = this.presetProvider.getPresetById(presetId);
}
const dropPolicySummary =
preset?.dropPolicySummary ?? this.deriveDropPolicySummary(scoringConfig);
const primaryChampionshipType =
preset?.primaryChampionshipType ??
(scoringConfig.championships[0]?.type ?? 'driver');
const scoringPresetName = preset?.name ?? 'Custom';
const scoringPatternSummary = `${scoringPresetName}${dropPolicySummary}`;
return {
gameId: game.id,
gameName: game.name,
primaryChampionshipType,
scoringPresetId: presetId ?? 'custom',
scoringPresetName,
dropPolicySummary,
scoringPatternSummary,
};
}
private deriveDropPolicySummary(config: {
championships: Array<{
dropScorePolicy: { strategy: string; count?: number; dropCount?: number };
}>;
}): string {
const championship = config.championships[0];
if (!championship) {
return 'All results count';
}
const policy = championship.dropScorePolicy;
if (!policy || policy.strategy === 'none') {
return 'All results count';
}
if (policy.strategy === 'bestNResults' && typeof policy.count === 'number') {
return `Best ${policy.count} results count`;
}
if (
policy.strategy === 'dropWorstN' &&
typeof policy.dropCount === 'number'
) {
return `Worst ${policy.dropCount} results are dropped`;
}
return 'Custom drop score rules';
this.presenter.present(enrichedLeagues);
}
}

View File

@@ -1,17 +1,22 @@
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { LeagueDTO } from '../dto/LeagueDTO';
import type { IAllLeaguesWithCapacityPresenter } from '../presenters/IAllLeaguesWithCapacityPresenter';
export class GetAllLeaguesWithCapacityQuery {
/**
* Use Case for retrieving all leagues with capacity information.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetAllLeaguesWithCapacityUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
public readonly presenter: IAllLeaguesWithCapacityPresenter,
) {}
async execute(): Promise<LeagueDTO[]> {
async execute(): Promise<void> {
const leagues = await this.leagueRepository.findAll();
const results: LeagueDTO[] = [];
const memberCounts = new Map<string, number>();
for (const league of leagues) {
const members = await this.leagueMembershipRepository.getLeagueMembers(league.id);
@@ -25,34 +30,9 @@ export class GetAllLeaguesWithCapacityQuery {
m.role === 'member'),
).length;
// Ensure we never expose an impossible state like 26/24:
// clamp maxDrivers to at least usedSlots at the application boundary.
const configuredMax = league.settings.maxDrivers ?? usedSlots;
const safeMaxDrivers = Math.max(configuredMax, usedSlots);
const dto: LeagueDTO = {
id: league.id,
name: league.name,
description: league.description,
ownerId: league.ownerId,
settings: {
...league.settings,
maxDrivers: safeMaxDrivers,
},
createdAt: league.createdAt.toISOString(),
socialLinks: league.socialLinks
? {
discordUrl: league.socialLinks.discordUrl,
youtubeUrl: league.socialLinks.youtubeUrl,
websiteUrl: league.socialLinks.websiteUrl,
}
: undefined,
usedSlots,
};
results.push(dto);
memberCounts.set(league.id, usedSlots);
}
return results;
this.presenter.present(leagues, memberCounts);
}
}

View File

@@ -1,13 +1,32 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { GetAllTeamsQueryResultDTO } from '../dto/TeamCommandAndQueryDTO';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { IAllTeamsPresenter } from '../presenters/IAllTeamsPresenter';
export class GetAllTeamsQuery {
/**
* Use Case for retrieving all teams.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetAllTeamsUseCase {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly teamMembershipRepository: ITeamMembershipRepository,
public readonly presenter: IAllTeamsPresenter,
) {}
async execute(): Promise<GetAllTeamsQueryResultDTO> {
async execute(): Promise<void> {
const teams = await this.teamRepository.findAll();
return teams;
// Enrich teams with member counts
const enrichedTeams = await Promise.all(
teams.map(async (team) => {
const memberships = await this.teamMembershipRepository.findByTeamId(team.id);
return {
...team,
memberCount: memberships.length,
};
})
);
this.presenter.present(enrichedTeams as any);
}
}

View File

@@ -1,29 +1,30 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type {
GetDriverTeamQueryParamsDTO,
GetDriverTeamQueryResultDTO,
} from '../dto/TeamCommandAndQueryDTO';
import type { IDriverTeamPresenter } from '../presenters/IDriverTeamPresenter';
export class GetDriverTeamQuery {
/**
* Use Case for retrieving a driver's team.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetDriverTeamUseCase {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository,
public readonly presenter: IDriverTeamPresenter,
) {}
async execute(params: GetDriverTeamQueryParamsDTO): Promise<GetDriverTeamQueryResultDTO | null> {
const { driverId } = params;
async execute(driverId: string): Promise<boolean> {
const membership = await this.membershipRepository.getActiveMembershipForDriver(driverId);
if (!membership) {
return null;
return false;
}
const team = await this.teamRepository.findById(membership.teamId);
if (!team) {
return null;
return false;
}
return { team, membership };
this.presenter.present(team, membership, driverId);
return true;
}
}

View File

@@ -0,0 +1,37 @@
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IRankingService } from '../../domain/services/IRankingService';
import type { IDriverStatsService } from '../../domain/services/IDriverStatsService';
import type { IImageService } from '../../domain/services/IImageService';
import type { IDriversLeaderboardPresenter } from '../presenters/IDriversLeaderboardPresenter';
/**
* Use Case for retrieving driver leaderboard data.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetDriversLeaderboardUseCase {
constructor(
private readonly driverRepository: IDriverRepository,
private readonly rankingService: IRankingService,
private readonly driverStatsService: IDriverStatsService,
private readonly imageService: IImageService,
public readonly presenter: IDriversLeaderboardPresenter,
) {}
async execute(): Promise<void> {
const drivers = await this.driverRepository.findAll();
const rankings = this.rankingService.getAllDriverRankings();
const stats: Record<string, any> = {};
const avatarUrls: Record<string, string> = {};
for (const driver of drivers) {
const driverStats = this.driverStatsService.getDriverStats(driver.id);
if (driverStats) {
stats[driver.id] = driverStats;
}
avatarUrls[driver.id] = this.imageService.getDriverAvatar(driver.id);
}
this.presenter.present(drivers, rankings, stats, avatarUrls);
}
}

View File

@@ -1,6 +1,6 @@
/**
* Query: GetEntitySponsorshipPricingQuery
*
* Application Use Case: GetEntitySponsorshipPricingUseCase
*
* Retrieves sponsorship pricing configuration for any entity.
* Used by sponsors to see available slots and prices.
*/
@@ -10,6 +10,7 @@ import type { ISponsorshipRequestRepository } from '../../domain/repositories/IS
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship';
import type { IEntitySponsorshipPricingPresenter } from '../presenters/IEntitySponsorshipPricingPresenter';
export interface GetEntitySponsorshipPricingDTO {
entityType: SponsorableEntityType;
@@ -37,18 +38,20 @@ export interface GetEntitySponsorshipPricingResultDTO {
secondarySlot?: SponsorshipSlotDTO;
}
export class GetEntitySponsorshipPricingQuery {
export class GetEntitySponsorshipPricingUseCase {
constructor(
private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository,
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,
private readonly presenter: IEntitySponsorshipPricingPresenter,
) {}
async execute(dto: GetEntitySponsorshipPricingDTO): Promise<GetEntitySponsorshipPricingResultDTO | null> {
async execute(dto: GetEntitySponsorshipPricingDTO): Promise<void> {
const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId);
if (!pricing) {
return null;
this.presenter.present(null);
return;
}
// Count pending requests by tier
@@ -107,6 +110,6 @@ export class GetEntitySponsorshipPricingQuery {
};
}
return result;
this.presenter.present(result);
}
}

View File

@@ -2,26 +2,31 @@ 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 { LeagueDriverSeasonStatsDTO } from '../dto/LeagueDriverSeasonStatsDTO';
import type { ILeagueDriverSeasonStatsPresenter } from '../presenters/ILeagueDriverSeasonStatsPresenter';
export interface DriverRatingPort {
getRating(driverId: string): { rating: number | null; ratingChange: number | null };
}
export interface GetLeagueDriverSeasonStatsQueryParamsDTO {
export interface GetLeagueDriverSeasonStatsUseCaseParams {
leagueId: string;
}
export class GetLeagueDriverSeasonStatsQuery {
/**
* Use Case for retrieving league driver season statistics.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetLeagueDriverSeasonStatsUseCase {
constructor(
private readonly standingRepository: IStandingRepository,
private readonly resultRepository: IResultRepository,
private readonly penaltyRepository: IPenaltyRepository,
private readonly raceRepository: IRaceRepository,
private readonly driverRatingPort: DriverRatingPort,
public readonly presenter: ILeagueDriverSeasonStatsPresenter,
) {}
async execute(params: GetLeagueDriverSeasonStatsQueryParamsDTO): Promise<LeagueDriverSeasonStatsDTO[]> {
async execute(params: GetLeagueDriverSeasonStatsUseCaseParams): Promise<void> {
const { leagueId } = params;
// Get standings and races for the league
@@ -53,59 +58,26 @@ export class GetLeagueDriverSeasonStatsQuery {
penaltiesByDriver.set(p.driverId, current);
}
// Build basic stats per driver from standings
const statsByDriver = new Map<string, LeagueDriverSeasonStatsDTO>();
// Collect driver ratings
const driverRatings = new Map<string, { rating: number | null; ratingChange: number | null }>();
for (const standing of standings) {
const penalty = penaltiesByDriver.get(standing.driverId) ?? { baseDelta: 0, bonusDelta: 0 };
const totalPenaltyPoints = penalty.baseDelta;
const bonusPoints = penalty.bonusDelta;
const racesCompleted = standing.racesCompleted;
const pointsPerRace = racesCompleted > 0 ? standing.points / racesCompleted : 0;
const ratingInfo = this.driverRatingPort.getRating(standing.driverId);
const dto: LeagueDriverSeasonStatsDTO = {
leagueId,
driverId: standing.driverId,
position: standing.position,
driverName: '',
teamId: undefined,
teamName: undefined,
totalPoints: standing.points + totalPenaltyPoints + bonusPoints,
basePoints: standing.points,
penaltyPoints: Math.abs(totalPenaltyPoints),
bonusPoints,
pointsPerRace,
racesStarted: racesCompleted,
racesFinished: racesCompleted,
dnfs: 0,
noShows: 0,
avgFinish: null,
rating: ratingInfo.rating,
ratingChange: ratingInfo.ratingChange,
};
statsByDriver.set(standing.driverId, dto);
driverRatings.set(standing.driverId, ratingInfo);
}
// Enhance stats with basic finish-position-based avgFinish from results
for (const [driverId, dto] of statsByDriver.entries()) {
const driverResults = await this.resultRepository.findByDriverIdAndLeagueId(driverId, leagueId);
if (driverResults.length > 0) {
const totalPositions = driverResults.reduce((sum, r) => sum + r.position, 0);
const avgFinish = totalPositions / driverResults.length;
dto.avgFinish = Number.isFinite(avgFinish) ? Number(avgFinish.toFixed(2)) : null;
dto.racesStarted = driverResults.length;
dto.racesFinished = driverResults.length;
}
statsByDriver.set(driverId, dto);
// Collect driver results
const driverResults = new Map<string, Array<{ position: number }>>();
for (const standing of standings) {
const results = await this.resultRepository.findByDriverIdAndLeagueId(standing.driverId, leagueId);
driverResults.set(standing.driverId, results);
}
// Ensure ordering by position
const result = Array.from(statsByDriver.values()).sort((a, b) => a.position - b.position);
return result;
this.presenter.present(
leagueId,
standings,
penaltiesByDriver,
driverResults,
driverRatings
);
}
}

View File

@@ -2,153 +2,52 @@ 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 { ChampionshipConfig } from '../../domain/value-objects/ChampionshipConfig';
import type { DropScorePolicy } from '../../domain/value-objects/DropScorePolicy';
import type {
LeagueConfigFormModel,
LeagueDropPolicyFormDTO,
} from '../dto/LeagueConfigFormDTO';
import type { ILeagueFullConfigPresenter, LeagueFullConfigData } from '../presenters/ILeagueFullConfigPresenter';
/**
* Query returning a unified LeagueConfigFormModel for a given league.
*
* First iteration focuses on:
* - Basics derived from League
* - Simple solo structure derived from League.settings.maxDrivers
* - Championships flags with driver enabled and others disabled
* - Scoring pattern id taken from LeagueScoringConfig.scoringPresetId
* - Drop policy inferred from the primary championship configuration
* Use Case for retrieving a league's full configuration.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetLeagueFullConfigQuery {
export class GetLeagueFullConfigUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
private readonly gameRepository: IGameRepository,
public readonly presenter: ILeagueFullConfigPresenter,
) {}
async execute(params: { leagueId: string }): Promise<LeagueConfigFormModel | null> {
async execute(params: { leagueId: string }): Promise<void> {
const { leagueId } = params;
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
return null;
throw new Error(`League ${leagueId} not found`);
}
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
const activeSeason =
seasons && seasons.length > 0
? seasons.find((s) => s.status === 'active') ?? seasons[0]
: null;
const scoringConfig = activeSeason
? await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id)
: null;
const game =
activeSeason && activeSeason.gameId
? await this.gameRepository.findById(activeSeason.gameId)
: null;
const patternId = scoringConfig?.scoringPresetId;
const primaryChampionship: ChampionshipConfig | undefined =
scoringConfig && scoringConfig.championships && scoringConfig.championships.length > 0
? scoringConfig.championships[0]
: undefined;
const dropPolicy: DropScorePolicy | undefined =
primaryChampionship?.dropScorePolicy ?? undefined;
let scoringConfig;
let game;
const dropPolicyForm: LeagueDropPolicyFormDTO = this.mapDropPolicy(dropPolicy);
const defaultQualifyingMinutes = 30;
const defaultMainRaceMinutes = 40;
const mainRaceMinutes =
typeof league.settings.sessionDuration === 'number'
? league.settings.sessionDuration
: defaultMainRaceMinutes;
const qualifyingMinutes = defaultQualifyingMinutes;
const roundsPlanned = 8;
let sessionCount = 2;
if (
primaryChampionship &&
Array.isArray((primaryChampionship as any).sessionTypes) &&
(primaryChampionship as any).sessionTypes.length > 0
) {
sessionCount = (primaryChampionship as any).sessionTypes.length;
if (activeSeason) {
scoringConfig = await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
if (activeSeason.gameId) {
game = await this.gameRepository.findById(activeSeason.gameId);
}
}
const practiceMinutes = 20;
const sprintRaceMinutes = patternId === 'sprint-main-driver' ? 20 : undefined;
const form: LeagueConfigFormModel = {
leagueId: league.id,
basics: {
name: league.name,
description: league.description,
visibility: 'public', // current domain model does not track visibility; default to public for now
gameId: game?.id ?? 'iracing',
},
structure: {
// First slice: treat everything as solo structure based on maxDrivers
mode: 'solo',
maxDrivers: league.settings.maxDrivers ?? 32,
maxTeams: undefined,
driversPerTeam: undefined,
multiClassEnabled: false,
},
championships: {
enableDriverChampionship: true,
enableTeamChampionship: false,
enableNationsChampionship: false,
enableTrophyChampionship: false,
},
scoring: {
patternId: patternId ?? undefined,
customScoringEnabled: !patternId,
},
dropPolicy: dropPolicyForm,
timings: {
practiceMinutes,
qualifyingMinutes,
sprintRaceMinutes,
mainRaceMinutes,
sessionCount,
roundsPlanned,
},
stewarding: {
decisionMode: 'admin_only',
requireDefense: true,
defenseTimeLimit: 48,
voteTimeLimit: 72,
protestDeadlineHours: 72,
stewardingClosesHours: 168,
notifyAccusedOnProtest: true,
notifyOnVoteRequired: true,
},
const data: LeagueFullConfigData = {
league,
activeSeason,
scoringConfig,
game,
};
return form;
}
private mapDropPolicy(policy: DropScorePolicy | undefined): LeagueDropPolicyFormDTO {
if (!policy || policy.strategy === 'none') {
return { strategy: 'none' };
}
if (policy.strategy === 'bestNResults') {
const n = typeof policy.count === 'number' ? policy.count : undefined;
return n !== undefined ? { strategy: 'bestNResults', n } : { strategy: 'none' };
}
if (policy.strategy === 'dropWorstN') {
const n = typeof policy.dropCount === 'number' ? policy.dropCount : undefined;
return n !== undefined ? { strategy: 'dropWorstN', n } : { strategy: 'none' };
}
return { strategy: 'none' };
this.presenter.present(data);
}
}

View File

@@ -2,41 +2,34 @@ 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 { LeagueScoringConfigDTO } from '../dto/LeagueScoringConfigDTO';
import type { LeagueScoringChampionshipDTO } from '../dto/LeagueScoringConfigDTO';
import type {
LeagueScoringPresetProvider,
LeagueScoringPresetDTO,
} from '../ports/LeagueScoringPresetProvider';
import type { ChampionshipConfig } from '../../domain/value-objects/ChampionshipConfig';
import type { PointsTable } from '../../domain/value-objects/PointsTable';
import type { BonusRule } from '../../domain/value-objects/BonusRule';
import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider';
import type { ILeagueScoringConfigPresenter, LeagueScoringConfigData } from '../presenters/ILeagueScoringConfigPresenter';
/**
* Query returning a league's scoring configuration for its active season.
*
* Designed for the league detail "Scoring" tab.
* Use Case for retrieving a league's scoring configuration for its active season.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetLeagueScoringConfigQuery {
export class GetLeagueScoringConfigUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
private readonly gameRepository: IGameRepository,
private readonly presetProvider: LeagueScoringPresetProvider,
public readonly presenter: ILeagueScoringConfigPresenter,
) {}
async execute(params: { leagueId: string }): Promise<LeagueScoringConfigDTO | null> {
async execute(params: { leagueId: string }): Promise<void> {
const { leagueId } = params;
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
return null;
throw new Error(`League ${leagueId} not found`);
}
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
if (!seasons || seasons.length === 0) {
return null;
throw new Error(`No seasons found for league ${leagueId}`);
}
const activeSeason =
@@ -45,146 +38,27 @@ export class GetLeagueScoringConfigQuery {
const scoringConfig =
await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
if (!scoringConfig) {
return null;
throw new Error(`No scoring config found for season ${activeSeason.id}`);
}
const game = await this.gameRepository.findById(activeSeason.gameId);
if (!game) {
return null;
throw new Error(`Game ${activeSeason.gameId} not found`);
}
const presetId = scoringConfig.scoringPresetId;
const preset: LeagueScoringPresetDTO | undefined =
presetId ? this.presetProvider.getPresetById(presetId) : undefined;
const preset = presetId ? this.presetProvider.getPresetById(presetId) : undefined;
const championships: LeagueScoringChampionshipDTO[] =
scoringConfig.championships.map((champ) =>
this.mapChampionship(champ),
);
const dropPolicySummary =
preset?.dropPolicySummary ??
this.deriveDropPolicyDescriptionFromChampionships(
scoringConfig.championships,
);
return {
const data: LeagueScoringConfigData = {
leagueId: league.id,
seasonId: activeSeason.id,
gameId: game.id,
gameName: game.name,
scoringPresetId: presetId,
scoringPresetName: preset?.name,
dropPolicySummary,
championships,
preset,
championships: scoringConfig.championships,
};
}
private mapChampionship(championship: ChampionshipConfig): LeagueScoringChampionshipDTO {
const sessionTypes = championship.sessionTypes.map((s) => s.toString());
const pointsPreview = this.buildPointsPreview(championship.pointsTableBySessionType);
const bonusSummary = this.buildBonusSummary(
championship.bonusRulesBySessionType ?? {},
);
const dropPolicyDescription = this.deriveDropPolicyDescription(
championship.dropScorePolicy,
);
return {
id: championship.id,
name: championship.name,
type: championship.type,
sessionTypes,
pointsPreview,
bonusSummary,
dropPolicyDescription,
};
}
private buildPointsPreview(
tables: Record<string, any>,
): Array<{ sessionType: string; position: number; points: number }> {
const preview: Array<{
sessionType: string;
position: number;
points: number;
}> = [];
const maxPositions = 10;
for (const [sessionType, table] of Object.entries(tables)) {
for (let pos = 1; pos <= maxPositions; pos++) {
const points = table.getPointsForPosition(pos);
if (points && points !== 0) {
preview.push({
sessionType,
position: pos,
points,
});
}
}
}
return preview;
}
private buildBonusSummary(
bonusRulesBySessionType: Record<string, BonusRule[]>,
): string[] {
const summaries: string[] = [];
for (const [sessionType, rules] of Object.entries(bonusRulesBySessionType)) {
for (const rule of rules) {
if (rule.type === 'fastestLap') {
const base = `Fastest lap in ${sessionType}`;
if (rule.requiresFinishInTopN) {
summaries.push(
`${base} +${rule.points} points if finishing P${rule.requiresFinishInTopN} or better`,
);
} else {
summaries.push(`${base} +${rule.points} points`);
}
} else {
summaries.push(
`${rule.type} bonus in ${sessionType} worth ${rule.points} points`,
);
}
}
}
return summaries;
}
private deriveDropPolicyDescriptionFromChampionships(
championships: ChampionshipConfig[],
): string {
const first = championships[0];
if (!first) {
return 'All results count';
}
return this.deriveDropPolicyDescription(first.dropScorePolicy);
}
private deriveDropPolicyDescription(policy: {
strategy: string;
count?: number;
dropCount?: number;
}): string {
if (!policy || policy.strategy === 'none') {
return 'All results count';
}
if (policy.strategy === 'bestNResults' && typeof policy.count === 'number') {
return `Best ${policy.count} results count towards the championship`;
}
if (
policy.strategy === 'dropWorstN' &&
typeof policy.dropCount === 'number'
) {
return `Worst ${policy.dropCount} results are dropped from the championship total`;
}
return 'Custom drop score rules apply';
this.presenter.present(data);
}
}

View File

@@ -1,18 +1,22 @@
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { StandingDTO } from '../dto/StandingDTO';
import { EntityMappers } from '../mappers/EntityMappers';
import type { ILeagueStandingsPresenter } from '../presenters/ILeagueStandingsPresenter';
export interface GetLeagueStandingsQueryParamsDTO {
export interface GetLeagueStandingsUseCaseParams {
leagueId: string;
}
export class GetLeagueStandingsQuery {
/**
* Use Case for retrieving league standings.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetLeagueStandingsUseCase {
constructor(
private readonly standingRepository: IStandingRepository,
public readonly presenter: ILeagueStandingsPresenter,
) {}
async execute(params: GetLeagueStandingsQueryParamsDTO): Promise<StandingDTO[]> {
async execute(params: GetLeagueStandingsUseCaseParams): Promise<void> {
const standings = await this.standingRepository.findByLeagueId(params.leagueId);
return EntityMappers.toStandingDTOs(standings);
this.presenter.present(standings);
}
}

View File

@@ -1,33 +1,26 @@
/**
* Application Query: GetLeagueStatsQuery
*
* Returns league statistics including average SOF across completed races.
* Use Case for retrieving league statistics.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
import type { ILeagueStatsPresenter } from '../presenters/ILeagueStatsPresenter';
import {
AverageStrengthOfFieldCalculator,
type StrengthOfFieldCalculator,
} from '../../domain/services/StrengthOfFieldCalculator';
export interface GetLeagueStatsQueryParams {
export interface GetLeagueStatsUseCaseParams {
leagueId: string;
}
export interface LeagueStatsDTO {
leagueId: string;
totalRaces: number;
completedRaces: number;
scheduledRaces: number;
averageSOF: number | null;
highestSOF: number | null;
lowestSOF: number | null;
}
export class GetLeagueStatsQuery {
/**
* Use Case for retrieving league statistics including average SOF across completed races.
*/
export class GetLeagueStatsUseCase {
private readonly sofCalculator: StrengthOfFieldCalculator;
constructor(
@@ -35,17 +28,18 @@ export class GetLeagueStatsQuery {
private readonly raceRepository: IRaceRepository,
private readonly resultRepository: IResultRepository,
private readonly driverRatingProvider: DriverRatingProvider,
public readonly presenter: ILeagueStatsPresenter,
sofCalculator?: StrengthOfFieldCalculator,
) {
this.sofCalculator = sofCalculator ?? new AverageStrengthOfFieldCalculator();
}
async execute(params: GetLeagueStatsQueryParams): Promise<LeagueStatsDTO | null> {
async execute(params: GetLeagueStatsUseCaseParams): Promise<void> {
const { leagueId } = params;
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
return null;
throw new Error(`League ${leagueId} not found`);
}
const races = await this.raceRepository.findByLeagueId(leagueId);
@@ -78,22 +72,12 @@ export class GetLeagueStatsQuery {
}
}
// Calculate aggregate stats
const averageSOF = sofValues.length > 0
? Math.round(sofValues.reduce((a, b) => a + b, 0) / sofValues.length)
: null;
const highestSOF = sofValues.length > 0 ? Math.max(...sofValues) : null;
const lowestSOF = sofValues.length > 0 ? Math.min(...sofValues) : null;
return {
this.presenter.present(
leagueId,
totalRaces: races.length,
completedRaces: completedRaces.length,
scheduledRaces: scheduledRaces.length,
averageSOF,
highestSOF,
lowestSOF,
};
races.length,
completedRaces.length,
scheduledRaces.length,
sofValues
);
}
}

View File

@@ -1,6 +1,6 @@
/**
* Query: GetPendingSponsorshipRequestsQuery
*
* Application Use Case: GetPendingSponsorshipRequestsUseCase
*
* Retrieves pending sponsorship requests for an entity owner to review.
*/
@@ -8,6 +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 { IPendingSponsorshipRequestsPresenter } from '../presenters/IPendingSponsorshipRequestsPresenter';
export interface GetPendingSponsorshipRequestsDTO {
entityType: SponsorableEntityType;
@@ -36,13 +37,14 @@ export interface GetPendingSponsorshipRequestsResultDTO {
totalCount: number;
}
export class GetPendingSponsorshipRequestsQuery {
export class GetPendingSponsorshipRequestsUseCase {
constructor(
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly sponsorRepo: ISponsorRepository,
private readonly presenter: IPendingSponsorshipRequestsPresenter,
) {}
async execute(dto: GetPendingSponsorshipRequestsDTO): Promise<GetPendingSponsorshipRequestsResultDTO> {
async execute(dto: GetPendingSponsorshipRequestsDTO): Promise<void> {
const requests = await this.sponsorshipRequestRepo.findPendingByEntity(
dto.entityType,
dto.entityId
@@ -72,11 +74,11 @@ export class GetPendingSponsorshipRequestsQuery {
// Sort by creation date (newest first)
requestDTOs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
return {
this.presenter.present({
entityType: dto.entityType,
entityId: dto.entityId,
requests: requestDTOs,
totalCount: requestDTOs.length,
};
});
}
}

View File

@@ -1,38 +1,22 @@
/**
* Application Query: GetRacePenaltiesQuery
*
* Use Case: GetRacePenaltiesUseCase
*
* Returns all penalties applied for a specific race, with driver details.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { PenaltyType, PenaltyStatus } from '../../domain/entities/Penalty';
import type { IRacePenaltiesPresenter } from '../presenters/IRacePenaltiesPresenter';
export interface RacePenaltyDTO {
id: string;
raceId: string;
driverId: string;
driverName: string;
type: PenaltyType;
value?: number;
reason: string;
protestId?: string;
issuedBy: string;
issuedByName: string;
status: PenaltyStatus;
description: string;
issuedAt: string;
appliedAt?: string;
notes?: string;
}
export class GetRacePenaltiesQuery {
export class GetRacePenaltiesUseCase {
constructor(
private readonly penaltyRepository: IPenaltyRepository,
private readonly driverRepository: IDriverRepository,
public readonly presenter: IRacePenaltiesPresenter,
) {}
async execute(raceId: string): Promise<RacePenaltyDTO[]> {
async execute(raceId: string): Promise<void> {
const penalties = await this.penaltyRepository.findByRaceId(raceId);
// Load all driver details in parallel
@@ -53,22 +37,6 @@ export class GetRacePenaltiesQuery {
}
});
return penalties.map(penalty => ({
id: penalty.id,
raceId: penalty.raceId,
driverId: penalty.driverId,
driverName: driverMap.get(penalty.driverId) || 'Unknown',
type: penalty.type,
value: penalty.value,
reason: penalty.reason,
protestId: penalty.protestId,
issuedBy: penalty.issuedBy,
issuedByName: driverMap.get(penalty.issuedBy) || 'Unknown',
status: penalty.status,
description: penalty.getDescription(),
issuedAt: penalty.issuedAt.toISOString(),
appliedAt: penalty.appliedAt?.toISOString(),
notes: penalty.notes,
}));
this.presenter.present(penalties, driverMap);
}
}

View File

@@ -1,38 +1,22 @@
/**
* Application Query: GetRaceProtestsQuery
*
* Use Case: GetRaceProtestsUseCase
*
* Returns all protests filed for a specific race, with driver details.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { ProtestStatus, ProtestIncident } from '../../domain/entities/Protest';
import type { IRaceProtestsPresenter } from '../presenters/IRaceProtestsPresenter';
export interface RaceProtestDTO {
id: string;
raceId: string;
protestingDriverId: string;
protestingDriverName: string;
accusedDriverId: string;
accusedDriverName: string;
incident: ProtestIncident;
comment?: string;
proofVideoUrl?: string;
status: ProtestStatus;
reviewedBy?: string;
reviewedByName?: string;
decisionNotes?: string;
filedAt: string;
reviewedAt?: string;
}
export class GetRaceProtestsQuery {
export class GetRaceProtestsUseCase {
constructor(
private readonly protestRepository: IProtestRepository,
private readonly driverRepository: IDriverRepository,
public readonly presenter: IRaceProtestsPresenter,
) {}
async execute(raceId: string): Promise<RaceProtestDTO[]> {
async execute(raceId: string): Promise<void> {
const protests = await this.protestRepository.findByRaceId(raceId);
// Load all driver details in parallel
@@ -56,22 +40,6 @@ export class GetRaceProtestsQuery {
}
});
return protests.map(protest => ({
id: protest.id,
raceId: protest.raceId,
protestingDriverId: protest.protestingDriverId,
protestingDriverName: driverMap.get(protest.protestingDriverId) || 'Unknown',
accusedDriverId: protest.accusedDriverId,
accusedDriverName: driverMap.get(protest.accusedDriverId) || 'Unknown',
incident: protest.incident,
comment: protest.comment,
proofVideoUrl: protest.proofVideoUrl,
status: protest.status,
reviewedBy: protest.reviewedBy,
reviewedByName: protest.reviewedBy ? driverMap.get(protest.reviewedBy) : undefined,
decisionNotes: protest.decisionNotes,
filedAt: protest.filedAt.toISOString(),
reviewedAt: protest.reviewedAt?.toISOString(),
}));
this.presenter.present(protests, driverMap);
}
}

View File

@@ -1,17 +1,22 @@
import type { IRaceRegistrationRepository } from '@gridpilot/racing/domain/repositories/IRaceRegistrationRepository';
import type { GetRaceRegistrationsQueryParamsDTO } from '../dto/RaceRegistrationQueryDTO';
import type { IRaceRegistrationsPresenter } from '../presenters/IRaceRegistrationsPresenter';
/**
* Query object returning registered driver IDs for a race.
* Mirrors legacy getRegisteredDrivers behavior.
* Use Case: GetRaceRegistrationsUseCase
*
* Returns registered driver IDs for a race.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetRaceRegistrationsQuery {
export class GetRaceRegistrationsUseCase {
constructor(
private readonly registrationRepository: IRaceRegistrationRepository,
public readonly presenter: IRaceRegistrationsPresenter,
) {}
async execute(params: GetRaceRegistrationsQueryParamsDTO): Promise<string[]> {
async execute(params: GetRaceRegistrationsQueryParamsDTO): Promise<void> {
const { raceId } = params;
return this.registrationRepository.getRegisteredDrivers(raceId);
const registeredDriverIds = await this.registrationRepository.getRegisteredDrivers(raceId);
this.presenter.present(registeredDriverIds);
}
}

View File

@@ -1,8 +1,9 @@
/**
* Application Query: GetRaceWithSOFQuery
*
* Use Case: GetRaceWithSOFUseCase
*
* Returns race details enriched with calculated Strength of Field (SOF).
* SOF is calculated from participant ratings if not already stored on the race.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
@@ -13,18 +14,13 @@ import {
AverageStrengthOfFieldCalculator,
type StrengthOfFieldCalculator,
} from '../../domain/services/StrengthOfFieldCalculator';
import type { RaceDTO } from '../dto/RaceDTO';
import type { IRaceWithSOFPresenter } from '../presenters/IRaceWithSOFPresenter';
export interface GetRaceWithSOFQueryParams {
raceId: string;
}
export interface RaceWithSOFDTO extends Omit<RaceDTO, 'strengthOfField'> {
strengthOfField: number | null;
participantCount: number;
}
export class GetRaceWithSOFQuery {
export class GetRaceWithSOFUseCase {
private readonly sofCalculator: StrengthOfFieldCalculator;
constructor(
@@ -32,12 +28,13 @@ export class GetRaceWithSOFQuery {
private readonly registrationRepository: IRaceRegistrationRepository,
private readonly resultRepository: IResultRepository,
private readonly driverRatingProvider: DriverRatingProvider,
public readonly presenter: IRaceWithSOFPresenter,
sofCalculator?: StrengthOfFieldCalculator,
) {
this.sofCalculator = sofCalculator ?? new AverageStrengthOfFieldCalculator();
}
async execute(params: GetRaceWithSOFQueryParams): Promise<RaceWithSOFDTO | null> {
async execute(params: GetRaceWithSOFQueryParams): Promise<void> {
const { raceId } = params;
const race = await this.raceRepository.findById(raceId);
@@ -69,20 +66,20 @@ export class GetRaceWithSOFQuery {
strengthOfField = this.sofCalculator.calculate(driverRatings);
}
return {
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,
status: race.status,
this.presenter.present(
race.id,
race.leagueId,
race.scheduledAt,
race.track,
race.trackId,
race.car,
race.carId,
race.sessionType,
race.status,
strengthOfField,
registeredCount: race.registeredCount ?? participantIds.length,
maxParticipants: race.maxParticipants,
participantCount: participantIds.length,
};
race.registeredCount ?? participantIds.length,
race.maxParticipants,
participantIds.length
);
}
}

View File

@@ -0,0 +1,38 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { IRacesPagePresenter } from '@gridpilot/racing/application/presenters/IRacesPagePresenter';
export class GetRacesPageDataUseCase {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
public readonly presenter: IRacesPagePresenter,
) {}
async execute(): Promise<void> {
const [allRaces, allLeagues] = await Promise.all([
this.raceRepository.findAll(),
this.leagueRepository.findAll(),
]);
const leagueMap = new Map(allLeagues.map(l => [l.id, l.name]));
const races = allRaces
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime())
.map(race => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt.toISOString(),
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(),
}));
this.presenter.present(races);
}
}

View File

@@ -1,6 +1,6 @@
/**
* Application Query: GetSponsorDashboardQuery
*
* Application Use Case: GetSponsorDashboardUseCase
*
* Returns sponsor dashboard metrics including sponsorships, impressions, and investment data.
*/
@@ -10,6 +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 { ISponsorDashboardPresenter } from '../presenters/ISponsorDashboardPresenter';
export interface GetSponsorDashboardQueryParams {
sponsorId: string;
@@ -46,7 +47,7 @@ export interface SponsorDashboardDTO {
};
}
export class GetSponsorDashboardQuery {
export class GetSponsorDashboardUseCase {
constructor(
private readonly sponsorRepository: ISponsorRepository,
private readonly seasonSponsorshipRepository: ISeasonSponsorshipRepository,
@@ -54,14 +55,16 @@ export class GetSponsorDashboardQuery {
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly raceRepository: IRaceRepository,
private readonly presenter: ISponsorDashboardPresenter,
) {}
async execute(params: GetSponsorDashboardQueryParams): Promise<SponsorDashboardDTO | null> {
async execute(params: GetSponsorDashboardQueryParams): Promise<void> {
const { sponsorId } = params;
const sponsor = await this.sponsorRepository.findById(sponsorId);
if (!sponsor) {
return null;
this.presenter.present(null);
return;
}
// Get all sponsorships for this sponsor
@@ -140,7 +143,7 @@ export class GetSponsorDashboardQuery {
? Math.min(100, (mainSponsorships * 30) + (sponsorships.length * 10))
: 0;
return {
this.presenter.present({
sponsorId,
sponsorName: sponsor.name,
metrics: {
@@ -159,6 +162,6 @@ export class GetSponsorDashboardQuery {
totalInvestment,
costPerThousandViews: Math.round(costPerThousandViews * 100) / 100,
},
};
});
}
}

View File

@@ -1,6 +1,6 @@
/**
* Application Query: GetSponsorSponsorshipsQuery
*
* Application Use Case: GetSponsorSponsorshipsUseCase
*
* Returns detailed sponsorship information for a sponsor's campaigns/sponsorships page.
*/
@@ -11,6 +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 { ISponsorSponsorshipsPresenter } from '../presenters/ISponsorSponsorshipsPresenter';
export interface GetSponsorSponsorshipsQueryParams {
sponsorId: string;
@@ -22,6 +23,8 @@ export interface SponsorshipDetailDTO {
leagueName: string;
seasonId: string;
seasonName: string;
seasonStartDate?: Date;
seasonEndDate?: Date;
tier: SponsorshipTier;
status: SponsorshipStatus;
pricing: {
@@ -59,7 +62,7 @@ export interface SponsorSponsorshipsDTO {
};
}
export class GetSponsorSponsorshipsQuery {
export class GetSponsorSponsorshipsUseCase {
constructor(
private readonly sponsorRepository: ISponsorRepository,
private readonly seasonSponsorshipRepository: ISeasonSponsorshipRepository,
@@ -67,14 +70,16 @@ export class GetSponsorSponsorshipsQuery {
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly raceRepository: IRaceRepository,
private readonly presenter: ISponsorSponsorshipsPresenter,
) {}
async execute(params: GetSponsorSponsorshipsQueryParams): Promise<SponsorSponsorshipsDTO | null> {
async execute(params: GetSponsorSponsorshipsQueryParams): Promise<void> {
const { sponsorId } = params;
const sponsor = await this.sponsorRepository.findById(sponsorId);
if (!sponsor) {
return null;
this.presenter.present(null);
return;
}
// Get all sponsorships for this sponsor
@@ -116,6 +121,8 @@ export class GetSponsorSponsorshipsQuery {
leagueName: league.name,
seasonId: season.id,
seasonName: season.name,
seasonStartDate: season.startDate,
seasonEndDate: season.endDate,
tier: sponsorship.tier,
status: sponsorship.status,
pricing: {
@@ -143,7 +150,7 @@ export class GetSponsorSponsorshipsQuery {
const activeSponsorships = sponsorships.filter(s => s.status === 'active').length;
return {
this.presenter.present({
sponsorId,
sponsorName: sponsor.name,
sponsorships: sponsorshipDetails,
@@ -154,6 +161,6 @@ export class GetSponsorSponsorshipsQuery {
totalPlatformFees,
currency: 'USD',
},
};
});
}
}

View File

@@ -1,19 +1,19 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type {
GetTeamDetailsQueryParamsDTO,
GetTeamDetailsQueryResultDTO,
} from '../dto/TeamCommandAndQueryDTO';
import type { ITeamDetailsPresenter } from '../presenters/ITeamDetailsPresenter';
export class GetTeamDetailsQuery {
/**
* Use Case for retrieving team details.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetTeamDetailsUseCase {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository,
public readonly presenter: ITeamDetailsPresenter,
) {}
async execute(params: GetTeamDetailsQueryParamsDTO): Promise<GetTeamDetailsQueryResultDTO> {
const { teamId, driverId } = params;
async execute(teamId: string, driverId: string): Promise<void> {
const team = await this.teamRepository.findById(teamId);
if (!team) {
throw new Error('Team not found');
@@ -21,6 +21,6 @@ export class GetTeamDetailsQuery {
const membership = await this.membershipRepository.getMembership(teamId, driverId);
return { team, membership };
this.presenter.present(team, membership, driverId);
}
}

View File

@@ -1,14 +1,34 @@
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { TeamJoinRequest } from '../../domain/entities/Team';
import type { GetTeamJoinRequestsQueryParamsDTO } from '../dto/TeamCommandAndQueryDTO';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IImageService } from '../../domain/services/IImageService';
import type { ITeamJoinRequestsPresenter } from '../presenters/ITeamJoinRequestsPresenter';
export class GetTeamJoinRequestsQuery {
/**
* Use Case for retrieving team join requests.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetTeamJoinRequestsUseCase {
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
private readonly driverRepository: IDriverRepository,
private readonly imageService: IImageService,
public readonly presenter: ITeamJoinRequestsPresenter,
) {}
async execute(params: GetTeamJoinRequestsQueryParamsDTO): Promise<TeamJoinRequest[]> {
const { teamId } = params;
return this.membershipRepository.getJoinRequests(teamId);
async execute(teamId: string): Promise<void> {
const requests = await this.membershipRepository.getJoinRequests(teamId);
const driverNames: Record<string, string> = {};
const avatarUrls: Record<string, string> = {};
for (const request of requests) {
const driver = await this.driverRepository.findById(request.driverId);
if (driver) {
driverNames[request.driverId] = driver.name;
}
avatarUrls[request.driverId] = this.imageService.getDriverAvatar(request.driverId);
}
this.presenter.present(requests, driverNames, avatarUrls);
}
}

View File

@@ -1,14 +1,34 @@
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { TeamMembership } from '../../domain/entities/Team';
import type { GetTeamMembersQueryParamsDTO } from '../dto/TeamCommandAndQueryDTO';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IImageService } from '../../domain/services/IImageService';
import type { ITeamMembersPresenter } from '../presenters/ITeamMembersPresenter';
export class GetTeamMembersQuery {
/**
* Use Case for retrieving team members.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetTeamMembersUseCase {
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
private readonly driverRepository: IDriverRepository,
private readonly imageService: IImageService,
public readonly presenter: ITeamMembersPresenter,
) {}
async execute(params: GetTeamMembersQueryParamsDTO): Promise<TeamMembership[]> {
const { teamId } = params;
return this.membershipRepository.getTeamMembers(teamId);
async execute(teamId: string): Promise<void> {
const memberships = await this.membershipRepository.getTeamMembers(teamId);
const driverNames: Record<string, string> = {};
const avatarUrls: Record<string, string> = {};
for (const membership of memberships) {
const driver = await this.driverRepository.findById(membership.driverId);
if (driver) {
driverNames[membership.driverId] = driver.name;
}
avatarUrls[membership.driverId] = this.imageService.getDriverAvatar(membership.driverId);
}
this.presenter.present(memberships, driverNames, avatarUrls);
}
}

View File

@@ -0,0 +1,77 @@
import { inject, injectable } from 'tsyringe';
import type { ITeamRepository } from '@gridpilot/racing/domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '@gridpilot/racing/domain/repositories/ITeamMembershipRepository';
import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository';
import type { ITeamsLeaderboardPresenter } from '@gridpilot/racing/application/presenters/ITeamsLeaderboardPresenter';
import { SkillLevelService } from '@gridpilot/racing/domain/services/SkillLevelService';
interface DriverStatsAdapter {
rating: number | null;
wins: number;
totalRaces: number;
}
@injectable()
export class GetTeamsLeaderboardUseCase {
constructor(
@inject('ITeamRepository') private readonly teamRepository: ITeamRepository,
@inject('ITeamMembershipRepository')
private readonly teamMembershipRepository: ITeamMembershipRepository,
@inject('IDriverRepository') private readonly driverRepository: IDriverRepository,
private readonly getDriverStats: (driverId: string) => DriverStatsAdapter | null,
public readonly presenter: ITeamsLeaderboardPresenter
) {}
async execute(): Promise<void> {
const allTeams = await this.teamRepository.findAll();
const teams: any[] = [];
await Promise.all(
allTeams.map(async (team) => {
const memberships = await this.teamMembershipRepository.findByTeamId(team.id);
const memberCount = memberships.length;
let ratingSum = 0;
let ratingCount = 0;
let totalWins = 0;
let totalRaces = 0;
for (const membership of memberships) {
const stats = this.getDriverStats(membership.driverId);
if (!stats) continue;
if (typeof stats.rating === 'number') {
ratingSum += stats.rating;
ratingCount += 1;
}
totalWins += stats.wins ?? 0;
totalRaces += stats.totalRaces ?? 0;
}
const averageRating = ratingCount > 0 ? ratingSum / ratingCount : null;
const performanceLevel = SkillLevelService.getTeamPerformanceLevel(averageRating);
teams.push({
id: team.id,
name: team.name,
memberCount,
rating: averageRating,
totalWins,
totalRaces,
performanceLevel,
isRecruiting: true,
createdAt: new Date(),
description: team.description,
specialization: team.specialization as 'endurance' | 'sprint' | 'mixed' | undefined,
region: team.region,
languages: team.languages,
});
})
);
const recruitingCount = teams.filter((t) => t.isRecruiting).length;
this.presenter.present(teams, recruitingCount);
}
}

View File

@@ -1,17 +1,22 @@
import type { IRaceRegistrationRepository } from '@gridpilot/racing/domain/repositories/IRaceRegistrationRepository';
import type { IsDriverRegisteredForRaceQueryParamsDTO } from '../dto/RaceRegistrationQueryDTO';
import type { IDriverRegistrationStatusPresenter } from '../presenters/IDriverRegistrationStatusPresenter';
/**
* Read-only wrapper around IRaceRegistrationRepository.isRegistered.
* Mirrors legacy isRegistered behavior.
* Use Case: IsDriverRegisteredForRaceUseCase
*
* Checks if a driver is registered for a specific race.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class IsDriverRegisteredForRaceQuery {
export class IsDriverRegisteredForRaceUseCase {
constructor(
private readonly registrationRepository: IRaceRegistrationRepository,
public readonly presenter: IDriverRegistrationStatusPresenter,
) {}
async execute(params: IsDriverRegisteredForRaceQueryParamsDTO): Promise<boolean> {
async execute(params: IsDriverRegisteredForRaceQueryParamsDTO): Promise<void> {
const { raceId, driverId } = params;
return this.registrationRepository.isRegistered(raceId, driverId);
const isRegistered = await this.registrationRepository.isRegistered(raceId, driverId);
this.presenter.present(isRegistered, raceId, driverId);
}
}

View File

@@ -1,18 +1,18 @@
import type {
LeagueScoringPresetDTO,
LeagueScoringPresetProvider,
} from '../ports/LeagueScoringPresetProvider';
import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider';
import type { ILeagueScoringPresetsPresenter } from '../presenters/ILeagueScoringPresetsPresenter';
/**
* Read-only query exposing league scoring presets for UI consumption.
*
* Backed by the in-memory preset registry via a LeagueScoringPresetProvider
* implementation in the infrastructure layer.
* Use Case for listing league scoring presets.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class ListLeagueScoringPresetsQuery {
constructor(private readonly presetProvider: LeagueScoringPresetProvider) {}
export class ListLeagueScoringPresetsUseCase {
constructor(
private readonly presetProvider: LeagueScoringPresetProvider,
public readonly presenter: ILeagueScoringPresetsPresenter,
) {}
async execute(): Promise<LeagueScoringPresetDTO[]> {
return this.presetProvider.listPresets();
async execute(): Promise<void> {
const presets = await this.presetProvider.listPresets();
this.presenter.present(presets);
}
}

View File

@@ -1,18 +1,20 @@
import { SeasonScheduleGenerator } from '../../domain/services/SeasonScheduleGenerator';
import type { LeagueSchedulePreviewDTO, LeagueScheduleDTO } from '../dto/LeagueScheduleDTO';
import { scheduleDTOToSeasonSchedule } from '../dto/LeagueScheduleDTO';
import type { ILeagueSchedulePreviewPresenter } from '../presenters/ILeagueSchedulePreviewPresenter';
interface PreviewLeagueScheduleQueryParams {
schedule: LeagueScheduleDTO;
maxRounds?: number;
}
export class PreviewLeagueScheduleQuery {
export class PreviewLeagueScheduleUseCase {
constructor(
private readonly scheduleGenerator: typeof SeasonScheduleGenerator = SeasonScheduleGenerator,
private readonly presenter: ILeagueSchedulePreviewPresenter,
) {}
execute(params: PreviewLeagueScheduleQueryParams): LeagueSchedulePreviewDTO {
execute(params: PreviewLeagueScheduleQueryParams): void {
const seasonSchedule = scheduleDTOToSeasonSchedule(params.schedule);
const maxRounds =
@@ -30,10 +32,10 @@ export class PreviewLeagueScheduleQuery {
const summary = this.buildSummary(params.schedule, rounds);
return {
this.presenter.present({
rounds,
summary,
};
});
}
private buildSummary(