This commit is contained in:
2025-12-12 01:11:36 +01:00
parent ec3ddc3a5c
commit 6a88fe93ab
125 changed files with 1513 additions and 803 deletions

View File

@@ -2,6 +2,7 @@ import type { AutomationEnginePort } from '../../../../application/ports/Automat
import type { HostedSessionConfig } from '../../../../domain/types/HostedSessionConfig';
import { StepId } from '../../../../domain/value-objects/StepId';
import type { IBrowserAutomation } from '../../../../application/ports/ScreenAutomationPort';
import type { HostedSessionConfig } from '../../../../domain/types/HostedSessionConfig';
import type { SessionRepositoryPort } from '../../../../application/ports/SessionRepositoryPort';
import { StepTransitionValidator } from '../../../../domain/services/StepTransitionValidator';
@@ -36,6 +37,34 @@ export class AutomationEngineAdapter implements AutomationEnginePort {
private readonly sessionRepository: SessionRepositoryPort
) {}
private toStepConfig(config: HostedSessionConfig): Record<string, unknown> {
const baseConfig: Record<string, unknown> = {
sessionName: config.sessionName,
trackId: config.trackId,
carIds: [...config.carIds],
};
if (config.serverName !== undefined) baseConfig.serverName = config.serverName;
if (config.password !== undefined) baseConfig.password = config.password;
if (config.adminPassword !== undefined) baseConfig.adminPassword = config.adminPassword;
if (config.maxDrivers !== undefined) baseConfig.maxDrivers = config.maxDrivers;
if (config.carSearch !== undefined) baseConfig.carSearch = config.carSearch;
if (config.trackSearch !== undefined) baseConfig.trackSearch = config.trackSearch;
if (config.weatherType !== undefined) baseConfig.weatherType = config.weatherType;
if (config.timeOfDay !== undefined) baseConfig.timeOfDay = config.timeOfDay;
if (config.sessionDuration !== undefined) baseConfig.sessionDuration = config.sessionDuration;
if (config.practiceLength !== undefined) baseConfig.practiceLength = config.practiceLength;
if (config.qualifyingLength !== undefined) baseConfig.qualifyingLength = config.qualifyingLength;
if (config.warmupLength !== undefined) baseConfig.warmupLength = config.warmupLength;
if (config.raceLength !== undefined) baseConfig.raceLength = config.raceLength;
if (config.startType !== undefined) baseConfig.startType = config.startType;
if (config.restarts !== undefined) baseConfig.restarts = config.restarts;
if (config.damageModel !== undefined) baseConfig.damageModel = config.damageModel;
if (config.trackState !== undefined) baseConfig.trackState = config.trackState;
return baseConfig;
}
async validateConfiguration(config: HostedSessionConfig): Promise<ValidationResult> {
if (!config.sessionName || config.sessionName.trim() === '') {
return { isValid: false, error: 'Session name is required' };
@@ -89,7 +118,7 @@ export class AutomationEngineAdapter implements AutomationEnginePort {
// Execute current step using the browser automation
if (this.browserAutomation.executeStep) {
const result = await this.browserAutomation.executeStep(currentStep, config as unknown as Record<string, unknown>);
const result = await this.browserAutomation.executeStep(currentStep, this.toStepConfig(config));
if (!result.success) {
const stepDescription = StepTransitionValidator.getStepDescription(currentStep);
const errorMessage = `Step ${currentStep.value} (${stepDescription}) failed: ${result.error}`;
@@ -117,7 +146,7 @@ export class AutomationEngineAdapter implements AutomationEnginePort {
if (nextStep.isFinalStep()) {
// Execute final step handler
if (this.browserAutomation.executeStep) {
const result = await this.browserAutomation.executeStep(nextStep, config as unknown as Record<string, unknown>);
const result = await this.browserAutomation.executeStep(nextStep, this.toStepConfig(config));
if (!result.success) {
const stepDescription = StepTransitionValidator.getStepDescription(nextStep);
const errorMessage = `Step ${nextStep.value} (${stepDescription}) failed: ${result.error}`;

View File

@@ -2,6 +2,7 @@ import type { AutomationEnginePort } from '../../../../application/ports/Automat
import type { HostedSessionConfig } from '../../../../domain/types/HostedSessionConfig';
import { StepId } from '../../../../domain/value-objects/StepId';
import type { IBrowserAutomation } from '../../../../application/ports/ScreenAutomationPort';
import type { HostedSessionConfig } from '../../../../domain/types/HostedSessionConfig';
import type { SessionRepositoryPort } from '../../../../application/ports/SessionRepositoryPort';
import { StepTransitionValidator } from '../../../../domain/services/StepTransitionValidator';
@@ -19,6 +20,34 @@ export class MockAutomationEngineAdapter implements AutomationEnginePort {
private readonly sessionRepository: SessionRepositoryPort
) {}
private toStepConfig(config: HostedSessionConfig): Record<string, unknown> {
const baseConfig: Record<string, unknown> = {
sessionName: config.sessionName,
trackId: config.trackId,
carIds: [...config.carIds],
};
if (config.serverName !== undefined) baseConfig.serverName = config.serverName;
if (config.password !== undefined) baseConfig.password = config.password;
if (config.adminPassword !== undefined) baseConfig.adminPassword = config.adminPassword;
if (config.maxDrivers !== undefined) baseConfig.maxDrivers = config.maxDrivers;
if (config.carSearch !== undefined) baseConfig.carSearch = config.carSearch;
if (config.trackSearch !== undefined) baseConfig.trackSearch = config.trackSearch;
if (config.weatherType !== undefined) baseConfig.weatherType = config.weatherType;
if (config.timeOfDay !== undefined) baseConfig.timeOfDay = config.timeOfDay;
if (config.sessionDuration !== undefined) baseConfig.sessionDuration = config.sessionDuration;
if (config.practiceLength !== undefined) baseConfig.practiceLength = config.practiceLength;
if (config.qualifyingLength !== undefined) baseConfig.qualifyingLength = config.qualifyingLength;
if (config.warmupLength !== undefined) baseConfig.warmupLength = config.warmupLength;
if (config.raceLength !== undefined) baseConfig.raceLength = config.raceLength;
if (config.startType !== undefined) baseConfig.startType = config.startType;
if (config.restarts !== undefined) baseConfig.restarts = config.restarts;
if (config.damageModel !== undefined) baseConfig.damageModel = config.damageModel;
if (config.trackState !== undefined) baseConfig.trackState = config.trackState;
return baseConfig;
}
async validateConfiguration(config: HostedSessionConfig): Promise<ValidationResult> {
if (!config.sessionName || config.sessionName.trim() === '') {
return { isValid: false, error: 'Session name is required' };
@@ -74,7 +103,7 @@ export class MockAutomationEngineAdapter implements AutomationEnginePort {
if (this.browserAutomation.executeStep) {
const result = await this.browserAutomation.executeStep(
currentStep,
config as unknown as Record<string, unknown>,
this.toStepConfig(config),
);
if (!result.success) {
const stepDescription = StepTransitionValidator.getStepDescription(currentStep);
@@ -105,7 +134,7 @@ export class MockAutomationEngineAdapter implements AutomationEnginePort {
if (this.browserAutomation.executeStep) {
const result = await this.browserAutomation.executeStep(
nextStep,
config as unknown as Record<string, unknown>,
this.toStepConfig(config),
);
if (!result.success) {
const stepDescription = StepTransitionValidator.getStepDescription(nextStep);

View File

@@ -1,5 +1,5 @@
{
"extends": "../../tsconfig.json",
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "..",
"outDir": "dist",

View File

@@ -1,5 +1,5 @@
{
"extends": "../../tsconfig.json",
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"declaration": true,

View File

@@ -1,5 +1,5 @@
{
"extends": "../../tsconfig.json",
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "dist",

View File

@@ -3,13 +3,14 @@ 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';
import type { Presenter } from '@gridpilot/shared/presentation';
export interface LeagueSummaryViewModel {
id: string;
name: string;
description: string;
ownerId: string;
createdAt: Date;
createdAt: string;
maxDrivers: number;
usedDriverSlots: number;
maxTeams?: number;
@@ -20,7 +21,7 @@ export interface LeagueSummaryViewModel {
scoring?: {
gameId: string;
gameName: string;
primaryChampionshipType: string;
primaryChampionshipType: 'driver' | 'team' | 'nations' | 'trophy';
scoringPresetId: string;
scoringPresetName: string;
dropPolicySummary: string;
@@ -42,6 +43,5 @@ export interface LeagueEnrichedData {
preset?: LeagueScoringPresetDTO;
}
export interface IAllLeaguesWithCapacityAndScoringPresenter {
present(enrichedLeagues: LeagueEnrichedData[]): AllLeaguesWithCapacityAndScoringViewModel;
}
export interface IAllLeaguesWithCapacityAndScoringPresenter
extends Presenter<LeagueEnrichedData[], AllLeaguesWithCapacityAndScoringViewModel> {}

View File

@@ -1,4 +1,5 @@
import type { League } from '../../domain/entities/League';
import type { Presenter } from '@gridpilot/shared/presentation';
export interface LeagueWithCapacityViewModel {
id: string;
@@ -24,9 +25,10 @@ export interface AllLeaguesWithCapacityViewModel {
totalCount: number;
}
export interface IAllLeaguesWithCapacityPresenter {
present(
leagues: League[],
memberCounts: Map<string, number>
): AllLeaguesWithCapacityViewModel;
}
export interface AllLeaguesWithCapacityResultDTO {
leagues: League[];
memberCounts: Map<string, number>;
}
export interface IAllLeaguesWithCapacityPresenter
extends Presenter<AllLeaguesWithCapacityResultDTO, AllLeaguesWithCapacityViewModel> {}

View File

@@ -1,3 +1,5 @@
import type { Presenter } from '@gridpilot/shared/presentation';
export type AllRacesStatus = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'all';
export interface AllRacesListItemViewModel {
@@ -21,7 +23,7 @@ export interface AllRacesPageViewModel {
filters: AllRacesFilterOptionsViewModel;
}
export interface IAllRacesPagePresenter {
present(viewModel: AllRacesPageViewModel): void;
getViewModel(): AllRacesPageViewModel | null;
}
export type AllRacesPageResultDTO = AllRacesPageViewModel;
export interface IAllRacesPagePresenter
extends Presenter<AllRacesPageResultDTO, AllRacesPageViewModel> {}

View File

@@ -1,4 +1,3 @@
import type { Team } from '../../domain/entities/Team';
import type { Presenter } from '@gridpilot/shared/presentation';
export interface TeamListItemViewModel {
@@ -19,7 +18,16 @@ export interface AllTeamsViewModel {
}
export interface AllTeamsResultDTO {
teams: Array<Team & { memberCount: number }>;
teams: Array<{
id: string;
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
createdAt: Date;
memberCount: number;
}>;
}
export interface IAllTeamsPresenter

View File

@@ -1,3 +1,5 @@
import type { Presenter } from '@gridpilot/shared/presentation';
export interface DashboardDriverSummaryViewModel {
id: string;
name: string;
@@ -82,7 +84,7 @@ export interface DashboardOverviewViewModel {
friends: DashboardFriendSummaryViewModel[];
}
export interface IDashboardOverviewPresenter {
present(viewModel: DashboardOverviewViewModel): void;
getViewModel(): DashboardOverviewViewModel | null;
}
export type DashboardOverviewResultDTO = DashboardOverviewViewModel;
export interface IDashboardOverviewPresenter
extends Presenter<DashboardOverviewResultDTO, DashboardOverviewViewModel> {}

View File

@@ -1,5 +1,6 @@
import type { Driver } from '../../domain/entities/Driver';
import type { SkillLevel } from '../../domain/services/SkillLevelService';
import type { Presenter } from '@gridpilot/shared/presentation';
export type { SkillLevel };
@@ -24,13 +25,21 @@ export interface DriversLeaderboardViewModel {
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;
export interface DriversLeaderboardResultDTO {
drivers: Driver[];
rankings: Array<{ driverId: string; rating: number; overallRank: number | null }>;
stats: Record<
string,
{
rating: number;
wins: number;
podiums: number;
totalRaces: number;
overallRank: number | null;
}
>;
avatarUrls: Record<string, string>;
}
getViewModel(): DriversLeaderboardViewModel;
}
export interface IDriversLeaderboardPresenter
extends Presenter<DriversLeaderboardResultDTO, DriversLeaderboardViewModel> {}

View File

@@ -1,3 +1,5 @@
import type { Presenter } from '@gridpilot/shared/presentation';
export interface LeagueDriverSeasonStatsItemViewModel {
leagueId: string;
driverId: string;
@@ -24,18 +26,18 @@ export interface LeagueDriverSeasonStatsViewModel {
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;
getViewModel(): LeagueDriverSeasonStatsViewModel;
}
export interface LeagueDriverSeasonStatsResultDTO {
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 }>;
}
export interface ILeagueDriverSeasonStatsPresenter
extends Presenter<LeagueDriverSeasonStatsResultDTO, LeagueDriverSeasonStatsViewModel> {}

View File

@@ -1,5 +1,6 @@
import type { ChampionshipConfig } from '../../domain/types/ChampionshipConfig';
import type { LeagueScoringPresetDTO } from '../ports/LeagueScoringPresetProvider';
import type { Presenter } from '@gridpilot/shared/presentation';
export interface LeagueScoringChampionshipViewModel {
id: string;
@@ -32,7 +33,5 @@ export interface LeagueScoringConfigData {
championships: ChampionshipConfig[];
}
export interface ILeagueScoringConfigPresenter {
present(data: LeagueScoringConfigData): LeagueScoringConfigViewModel;
getViewModel(): LeagueScoringConfigViewModel;
}
export interface ILeagueScoringConfigPresenter
extends Presenter<LeagueScoringConfigData, LeagueScoringConfigViewModel> {}

View File

@@ -1,4 +1,5 @@
import type { SessionType, RaceStatus } from '../../domain/entities/Race';
import type { Presenter } from '@gridpilot/shared/presentation';
export interface RaceDetailEntryViewModel {
id: string;
@@ -55,7 +56,5 @@ export interface RaceDetailViewModel {
error?: string;
}
export interface IRaceDetailPresenter {
present(viewModel: RaceDetailViewModel): RaceDetailViewModel;
getViewModel(): RaceDetailViewModel | null;
}
export interface IRaceDetailPresenter
extends Presenter<RaceDetailViewModel, RaceDetailViewModel> {}

View File

@@ -1,4 +1,4 @@
import type { PenaltyType, PenaltyStatus } from '../../domain/entities/Penalty';
import type { Penalty, PenaltyType, PenaltyStatus } from '../../domain/entities/Penalty';
import type { Presenter } from '@gridpilot/shared/presentation/Presenter';
export interface RacePenaltyViewModel {
@@ -24,21 +24,7 @@ export interface RacePenaltiesViewModel {
}
export interface RacePenaltiesResultDTO {
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;
}>;
penalties: Penalty[];
driverMap: Map<string, string>;
}

View File

@@ -1,4 +1,4 @@
import type { ProtestStatus, ProtestIncident } from '../../domain/entities/Protest';
import type { Protest, ProtestStatus, ProtestIncident } from '../../domain/entities/Protest';
import type { Presenter } from '@gridpilot/shared/presentation/Presenter';
export interface RaceProtestViewModel {
@@ -24,20 +24,7 @@ export interface RaceProtestsViewModel {
}
export interface RaceProtestsResultDTO {
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;
}>;
protests: Protest[];
driverMap: Map<string, string>;
}

View File

@@ -1,9 +1,13 @@
import type { Presenter } from '@gridpilot/shared/presentation';
export interface RaceRegistrationsViewModel {
registeredDriverIds: string[];
count: number;
}
export interface IRaceRegistrationsPresenter {
present(registeredDriverIds: string[]): RaceRegistrationsViewModel;
getViewModel(): RaceRegistrationsViewModel;
}
export interface RaceRegistrationsResultDTO {
registeredDriverIds: string[];
}
export interface IRaceRegistrationsPresenter
extends Presenter<RaceRegistrationsResultDTO, RaceRegistrationsViewModel> {}

View File

@@ -2,6 +2,7 @@ import type { RaceStatus } from '../../domain/entities/Race';
import type { Result } from '../../domain/entities/Result';
import type { Driver } from '../../domain/entities/Driver';
import type { PenaltyType } from '../../domain/entities/Penalty';
import type { Presenter } from '@gridpilot/shared/presentation';
export interface RaceResultsHeaderViewModel {
id: string;
@@ -28,13 +29,11 @@ export interface RaceResultsDetailViewModel {
results: Result[];
drivers: Driver[];
penalties: RaceResultsPenaltySummaryViewModel[];
pointsSystem: Record<number, number>;
pointsSystem?: Record<number, number>;
fastestLapTime?: number;
currentDriverId?: string;
error?: string;
}
export interface IRaceResultsDetailPresenter {
present(viewModel: RaceResultsDetailViewModel): RaceResultsDetailViewModel;
getViewModel(): RaceResultsDetailViewModel | null;
}
export interface IRaceResultsDetailPresenter
extends Presenter<RaceResultsDetailViewModel, RaceResultsDetailViewModel> {}

View File

@@ -1,3 +1,5 @@
import type { Presenter } from '@gridpilot/shared/presentation';
export interface RaceWithSOFViewModel {
id: string;
leagueId: string;
@@ -14,21 +16,21 @@ export interface RaceWithSOFViewModel {
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;
}
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;
}
export interface IRaceWithSOFPresenter
extends Presenter<RaceWithSOFResultDTO, RaceWithSOFViewModel> {}

View File

@@ -1,3 +1,5 @@
import type { Presenter } from '@gridpilot/shared/presentation';
export interface RaceListItemViewModel {
id: string;
track: string;
@@ -25,7 +27,9 @@ export interface RacesPageViewModel {
recentResults: RaceListItemViewModel[];
}
export interface IRacesPagePresenter {
present(races: any[]): void;
getViewModel(): RacesPageViewModel;
}
export interface RacesPageResultDTO {
races: any[];
}
export interface IRacesPagePresenter
extends Presenter<RacesPageResultDTO, RacesPageViewModel> {}

View File

@@ -1,5 +1,7 @@
import type { SponsoredLeagueDTO, SponsorDashboardDTO } from '../use-cases/GetSponsorDashboardUseCase';
import type { SponsorDashboardDTO } from '../use-cases/GetSponsorDashboardUseCase';
import type { Presenter } from '@gridpilot/shared/presentation';
export interface ISponsorDashboardPresenter {
present(data: SponsorDashboardDTO | null): void;
}
export type SponsorDashboardViewModel = SponsorDashboardDTO | null;
export interface ISponsorDashboardPresenter
extends Presenter<SponsorDashboardDTO | null, SponsorDashboardViewModel> {}

View File

@@ -1,5 +1,7 @@
import type { SponsorSponsorshipsDTO } from '../use-cases/GetSponsorSponsorshipsUseCase';
import type { Presenter } from '@gridpilot/shared/presentation';
export interface ISponsorSponsorshipsPresenter {
present(data: SponsorSponsorshipsDTO | null): void;
}
export type SponsorSponsorshipsViewModel = SponsorSponsorshipsDTO | null;
export interface ISponsorSponsorshipsPresenter
extends Presenter<SponsorSponsorshipsDTO | null, SponsorSponsorshipsViewModel> {}

View File

@@ -1,5 +1,6 @@
import type { Team } from '../../domain/entities/Team';
import type { TeamMembership } from '../../domain/types/TeamMembership';
import type { Presenter } from '@gridpilot/shared/presentation';
export interface TeamDetailsViewModel {
team: {
@@ -9,9 +10,7 @@ export interface TeamDetailsViewModel {
description: string;
ownerId: string;
leagues: string[];
specialization?: 'endurance' | 'sprint' | 'mixed';
region?: string;
languages?: string[];
createdAt: string;
};
membership: {
role: 'owner' | 'manager' | 'member';
@@ -21,10 +20,11 @@ export interface TeamDetailsViewModel {
canManage: boolean;
}
export interface ITeamDetailsPresenter {
present(
team: Team,
membership: TeamMembership | null,
driverId: string
): TeamDetailsViewModel;
}
export interface TeamDetailsResultDTO {
team: Team;
membership: TeamMembership | null;
driverId: string;
}
export interface ITeamDetailsPresenter
extends Presenter<TeamDetailsResultDTO, TeamDetailsViewModel> {}

View File

@@ -27,15 +27,14 @@ export class CreateTeamUseCase {
throw new Error('Driver already belongs to a team');
}
const team: Team = {
const team = Team.create({
id: `team-${Date.now()}`,
name,
tag,
description,
ownerId,
leagues,
createdAt: new Date(),
};
});
const createdTeam = await this.teamRepository.create(team);

View File

@@ -4,15 +4,25 @@ 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 { IAllLeaguesWithCapacityAndScoringPresenter, LeagueEnrichedData } from '../presenters/IAllLeaguesWithCapacityAndScoringPresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type {
AllLeaguesWithCapacityAndScoringViewModel,
IAllLeaguesWithCapacityAndScoringPresenter,
LeagueEnrichedData,
} from '../presenters/IAllLeaguesWithCapacityAndScoringPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
/**
* Use Case for retrieving all leagues with capacity and scoring information.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetAllLeaguesWithCapacityAndScoringUseCase
implements AsyncUseCase<void, void>
implements
UseCase<
void,
LeagueEnrichedData[],
AllLeaguesWithCapacityAndScoringViewModel,
IAllLeaguesWithCapacityAndScoringPresenter
>
{
constructor(
private readonly leagueRepository: ILeagueRepository,
@@ -21,10 +31,14 @@ export class GetAllLeaguesWithCapacityAndScoringUseCase
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
private readonly gameRepository: IGameRepository,
private readonly presetProvider: LeagueScoringPresetProvider,
public readonly presenter: IAllLeaguesWithCapacityAndScoringPresenter,
) {}
async execute(): Promise<void> {
async execute(
_input: void,
presenter: IAllLeaguesWithCapacityAndScoringPresenter,
): Promise<void> {
presenter.reset();
const leagues = await this.leagueRepository.findAll();
const enrichedLeagues: LeagueEnrichedData[] = [];
@@ -42,18 +56,22 @@ export class GetAllLeaguesWithCapacityAndScoringUseCase
).length;
const seasons = await this.seasonRepository.findByLeagueId(league.id);
const activeSeason = seasons && seasons.length > 0
? seasons.find((s) => s.status === 'active') ?? seasons[0]
: undefined;
const activeSeason =
seasons && seasons.length > 0
? seasons.find((s) => s.status === 'active') ?? seasons[0]
: undefined;
let scoringConfig: LeagueEnrichedData['scoringConfig'];
let game: LeagueEnrichedData['game'];
let preset: LeagueEnrichedData['preset'];
if (activeSeason) {
scoringConfig = await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
const scoringConfigResult =
await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
scoringConfig = scoringConfigResult ?? undefined;
if (scoringConfig) {
game = await this.gameRepository.findById(activeSeason.gameId);
const gameResult = await this.gameRepository.findById(activeSeason.gameId);
game = gameResult ?? undefined;
const presetId = scoringConfig.scoringPresetId;
if (presetId) {
preset = this.presetProvider.getPresetById(presetId);
@@ -64,13 +82,13 @@ export class GetAllLeaguesWithCapacityAndScoringUseCase
enrichedLeagues.push({
league,
usedDriverSlots,
season: activeSeason,
...(scoringConfig ?? undefined ? { scoringConfig } : {}),
...(game ?? undefined ? { game } : {}),
...(preset ?? undefined ? { preset } : {}),
...(activeSeason ? { season: activeSeason } : {}),
...(scoringConfig ? { scoringConfig } : {}),
...(game ? { game } : {}),
...(preset ? { preset } : {}),
});
}
this.presenter.present(enrichedLeagues);
presenter.present(enrichedLeagues);
}
}

View File

@@ -1,22 +1,30 @@
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IAllLeaguesWithCapacityPresenter } from '../presenters/IAllLeaguesWithCapacityPresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type {
IAllLeaguesWithCapacityPresenter,
AllLeaguesWithCapacityResultDTO,
AllLeaguesWithCapacityViewModel,
} from '../presenters/IAllLeaguesWithCapacityPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
/**
* Use Case for retrieving all leagues with capacity information.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetAllLeaguesWithCapacityUseCase
implements AsyncUseCase<void, void>
implements UseCase<void, AllLeaguesWithCapacityResultDTO, AllLeaguesWithCapacityViewModel, IAllLeaguesWithCapacityPresenter>
{
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
public readonly presenter: IAllLeaguesWithCapacityPresenter,
) {}
async execute(): Promise<void> {
async execute(
_input: void,
presenter: IAllLeaguesWithCapacityPresenter,
): Promise<void> {
presenter.reset();
const leagues = await this.leagueRepository.findAll();
const memberCounts = new Map<string, number>();
@@ -36,6 +44,11 @@ export class GetAllLeaguesWithCapacityUseCase
memberCounts.set(league.id, usedSlots);
}
this.presenter.present(leagues, memberCounts);
const dto: AllLeaguesWithCapacityResultDTO = {
leagues,
memberCounts,
};
presenter.present(dto);
}
}

View File

@@ -2,21 +2,21 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type {
IAllRacesPagePresenter,
AllRacesPageResultDTO,
AllRacesPageViewModel,
AllRacesListItemViewModel,
AllRacesFilterOptionsViewModel,
} from '../presenters/IAllRacesPagePresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type { UseCase } from '@gridpilot/shared/application';
export class GetAllRacesPageDataUseCase
implements AsyncUseCase<void, void> {
implements UseCase<void, AllRacesPageResultDTO, AllRacesPageViewModel, IAllRacesPagePresenter> {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
public readonly presenter: IAllRacesPagePresenter,
) {}
async execute(): Promise<void> {
async execute(_input: void, presenter: IAllRacesPagePresenter): Promise<void> {
const [allRaces, allLeagues] = await Promise.all([
this.raceRepository.findAll(),
this.leagueRepository.findAll(),
@@ -59,6 +59,7 @@ export class GetAllRacesPageDataUseCase
filters,
};
this.presenter.present(viewModel);
presenter.reset();
presenter.present(viewModel);
}
}

View File

@@ -24,11 +24,17 @@ export class GetAllTeamsUseCase
const teams = await this.teamRepository.findAll();
const enrichedTeams: Array<Team & { memberCount: number }> = await Promise.all(
const enrichedTeams: AllTeamsResultDTO['teams'] = await Promise.all(
teams.map(async (team) => {
const memberCount = await this.teamMembershipRepository.countByTeamId(team.id);
return {
...team,
id: team.id,
name: team.name,
tag: team.tag,
description: team.description,
ownerId: team.ownerId,
leagues: [...team.leagues],
createdAt: team.createdAt,
memberCount,
};
}),

View File

@@ -8,7 +8,6 @@ import type { IRaceRegistrationRepository } from '../../domain/repositories/IRac
import type { IImageServicePort } from '../ports/IImageServicePort';
import type { IFeedRepository } from '@gridpilot/social/domain/repositories/IFeedRepository';
import type { ISocialGraphRepository } from '@gridpilot/social/domain/repositories/ISocialGraphRepository';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type {
IDashboardOverviewPresenter,
DashboardOverviewViewModel,
@@ -34,8 +33,7 @@ export interface GetDashboardOverviewParams {
driverId: string;
}
export class GetDashboardOverviewUseCase
implements AsyncUseCase<GetDashboardOverviewParams, void> {
export class GetDashboardOverviewUseCase {
constructor(
private readonly driverRepository: IDriverRepository,
private readonly raceRepository: IRaceRepository,
@@ -48,10 +46,9 @@ export class GetDashboardOverviewUseCase
private readonly socialRepository: ISocialGraphRepository,
private readonly imageService: IImageServicePort,
private readonly getDriverStats: (driverId: string) => DashboardDriverStatsAdapter | null,
public readonly presenter: IDashboardOverviewPresenter,
) {}
async execute(params: GetDashboardOverviewParams): Promise<void> {
async execute(params: GetDashboardOverviewParams, presenter: IDashboardOverviewPresenter): Promise<void> {
const { driverId } = params;
const [driver, allLeagues, allRaces, allResults, feedItems, friends] = await Promise.all([
@@ -137,7 +134,8 @@ export class GetDashboardOverviewUseCase
friends: friendsSummary,
};
this.presenter.present(viewModel);
presenter.reset();
presenter.present(viewModel);
}
private async getDriverLeagues(allLeagues: any[], driverId: string): Promise<any[]> {

View File

@@ -2,30 +2,36 @@ import type { IDriverRepository } from '../../domain/repositories/IDriverReposit
import type { IRankingService } from '../../domain/services/IRankingService';
import type { IDriverStatsService } from '../../domain/services/IDriverStatsService';
import type { IImageServicePort } from '../ports/IImageServicePort';
import type { IDriversLeaderboardPresenter } from '../presenters/IDriversLeaderboardPresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type {
IDriversLeaderboardPresenter,
DriversLeaderboardResultDTO,
DriversLeaderboardViewModel,
} from '../presenters/IDriversLeaderboardPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
/**
* Use Case for retrieving driver leaderboard data.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetDriversLeaderboardUseCase
implements AsyncUseCase<void, void> {
implements UseCase<void, DriversLeaderboardResultDTO, DriversLeaderboardViewModel, IDriversLeaderboardPresenter>
{
constructor(
private readonly driverRepository: IDriverRepository,
private readonly rankingService: IRankingService,
private readonly driverStatsService: IDriverStatsService,
private readonly imageService: IImageServicePort,
public readonly presenter: IDriversLeaderboardPresenter,
) {}
async execute(): Promise<void> {
async execute(_input: void, presenter: IDriversLeaderboardPresenter): Promise<void> {
presenter.reset();
const drivers = await this.driverRepository.findAll();
const rankings = this.rankingService.getAllDriverRankings();
const stats: Record<string, any> = {};
const avatarUrls: Record<string, string> = {};
const stats: DriversLeaderboardResultDTO['stats'] = {};
const avatarUrls: DriversLeaderboardResultDTO['avatarUrls'] = {};
for (const driver of drivers) {
const driverStats = this.driverStatsService.getDriverStats(driver.id);
if (driverStats) {
@@ -33,7 +39,14 @@ export class GetDriversLeaderboardUseCase
}
avatarUrls[driver.id] = this.imageService.getDriverAvatar(driver.id);
}
this.presenter.present(drivers, rankings, stats, avatarUrls);
const dto: DriversLeaderboardResultDTO = {
drivers,
rankings,
stats,
avatarUrls,
};
presenter.present(dto);
}
}

View File

@@ -2,8 +2,12 @@ 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 { ILeagueDriverSeasonStatsPresenter } from '../presenters/ILeagueDriverSeasonStatsPresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type {
ILeagueDriverSeasonStatsPresenter,
LeagueDriverSeasonStatsResultDTO,
LeagueDriverSeasonStatsViewModel,
} from '../presenters/ILeagueDriverSeasonStatsPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
export interface DriverRatingPort {
getRating(driverId: string): { rating: number | null; ratingChange: number | null };
@@ -18,17 +22,27 @@ export interface GetLeagueDriverSeasonStatsUseCaseParams {
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetLeagueDriverSeasonStatsUseCase
implements AsyncUseCase<GetLeagueDriverSeasonStatsUseCaseParams, void> {
implements
UseCase<
GetLeagueDriverSeasonStatsUseCaseParams,
LeagueDriverSeasonStatsResultDTO,
LeagueDriverSeasonStatsViewModel,
ILeagueDriverSeasonStatsPresenter
>
{
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: GetLeagueDriverSeasonStatsUseCaseParams): Promise<void> {
async execute(
params: GetLeagueDriverSeasonStatsUseCaseParams,
presenter: ILeagueDriverSeasonStatsPresenter,
): Promise<void> {
presenter.reset();
const { leagueId } = params;
// Get standings and races for the league
@@ -70,16 +84,26 @@ export class GetLeagueDriverSeasonStatsUseCase
// 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);
const results = await this.resultRepository.findByDriverIdAndLeagueId(
standing.driverId,
leagueId,
);
driverResults.set(standing.driverId, results);
}
this.presenter.present(
const dto: LeagueDriverSeasonStatsResultDTO = {
leagueId,
standings,
penaltiesByDriver,
standings: standings.map(standing => ({
driverId: standing.driverId,
position: standing.position,
points: standing.points,
racesCompleted: standing.racesCompleted,
})),
penalties: penaltiesByDriver,
driverResults,
driverRatings
);
driverRatings,
};
presenter.present(dto);
}
}

View File

@@ -49,9 +49,9 @@ export class GetLeagueFullConfigUseCase
const data: LeagueFullConfigData = {
league,
activeSeason,
...(scoringConfig ?? undefined ? { scoringConfig } : {}),
...(game ?? undefined ? { game } : {}),
...(activeSeason ? { activeSeason } : {}),
...(scoringConfig ? { scoringConfig } : {}),
...(game ? { game } : {}),
};
presenter.reset();

View File

@@ -3,25 +3,29 @@ 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 { ILeagueScoringConfigPresenter, LeagueScoringConfigData } from '../presenters/ILeagueScoringConfigPresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type {
ILeagueScoringConfigPresenter,
LeagueScoringConfigData,
LeagueScoringConfigViewModel,
} from '../presenters/ILeagueScoringConfigPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
/**
* Use Case for retrieving a league's scoring configuration for its active season.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetLeagueScoringConfigUseCase
implements AsyncUseCase<{ leagueId: string }, void> {
implements UseCase<{ leagueId: string }, LeagueScoringConfigData, LeagueScoringConfigViewModel, ILeagueScoringConfigPresenter>
{
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<void> {
async execute(params: { leagueId: string }, presenter: ILeagueScoringConfigPresenter): Promise<void> {
const { leagueId } = params;
const league = await this.leagueRepository.findById(leagueId);
@@ -65,6 +69,7 @@ export class GetLeagueScoringConfigUseCase
championships: scoringConfig.championships,
};
this.presenter.present(data);
presenter.reset();
presenter.present(data);
}
}

View File

@@ -14,6 +14,7 @@ import type {
RaceDetailEntryViewModel,
RaceDetailUserResultViewModel,
} from '../presenters/IRaceDetailPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
/**
* Use Case: GetRaceDetailUseCase
@@ -30,7 +31,9 @@ export interface GetRaceDetailQueryParams {
driverId: string;
}
export class GetRaceDetailUseCase {
export class GetRaceDetailUseCase
implements UseCase<GetRaceDetailQueryParams, RaceDetailViewModel, RaceDetailViewModel, IRaceDetailPresenter>
{
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
@@ -40,10 +43,11 @@ export class GetRaceDetailUseCase {
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly driverRatingProvider: DriverRatingProvider,
private readonly imageService: IImageServicePort,
public readonly presenter: IRaceDetailPresenter,
) {}
async execute(params: GetRaceDetailQueryParams): Promise<void> {
async execute(params: GetRaceDetailQueryParams, presenter: IRaceDetailPresenter): Promise<void> {
presenter.reset();
const { raceId, driverId } = params;
const race = await this.raceRepository.findById(raceId);
@@ -59,7 +63,7 @@ export class GetRaceDetailUseCase {
userResult: null,
error: 'Race not found',
};
this.presenter.present(emptyViewModel);
presenter.present(emptyViewModel);
return;
}
@@ -121,8 +125,8 @@ export class GetRaceDetailUseCase {
sessionType: race.sessionType,
status: race.status,
strengthOfField: race.strengthOfField ?? null,
registeredCount: race.registeredCount,
maxParticipants: race.maxParticipants,
...(race.registeredCount !== undefined ? { registeredCount: race.registeredCount } : {}),
...(race.maxParticipants !== undefined ? { maxParticipants: race.maxParticipants } : {}),
};
const leagueView: RaceDetailLeagueViewModel | null = league
@@ -131,8 +135,12 @@ export class GetRaceDetailUseCase {
name: league.name,
description: league.description,
settings: {
maxDrivers: league.settings.maxDrivers,
qualifyingFormat: league.settings.qualifyingFormat,
...(league.settings.maxDrivers !== undefined
? { maxDrivers: league.settings.maxDrivers }
: {}),
...(league.settings.qualifyingFormat !== undefined
? { qualifyingFormat: league.settings.qualifyingFormat }
: {}),
},
}
: null;
@@ -148,7 +156,7 @@ export class GetRaceDetailUseCase {
userResult: userResultView,
};
this.presenter.present(viewModel);
presenter.present(viewModel);
}
private calculateRatingChange(position: number): number {

View File

@@ -1,6 +1,11 @@
import type { IRaceRegistrationRepository } from '@gridpilot/racing/domain/repositories/IRaceRegistrationRepository';
import type { GetRaceRegistrationsQueryParamsDTO } from '../dto/RaceRegistrationQueryDTO';
import type { IRaceRegistrationsPresenter } from '../presenters/IRaceRegistrationsPresenter';
import type {
IRaceRegistrationsPresenter,
RaceRegistrationsResultDTO,
RaceRegistrationsViewModel,
} from '../presenters/IRaceRegistrationsPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
/**
* Use Case: GetRaceRegistrationsUseCase
@@ -8,15 +13,26 @@ import type { IRaceRegistrationsPresenter } from '../presenters/IRaceRegistratio
* Returns registered driver IDs for a race.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetRaceRegistrationsUseCase {
export class GetRaceRegistrationsUseCase
implements UseCase<GetRaceRegistrationsQueryParamsDTO, RaceRegistrationsResultDTO, RaceRegistrationsViewModel, IRaceRegistrationsPresenter>
{
constructor(
private readonly registrationRepository: IRaceRegistrationRepository,
public readonly presenter: IRaceRegistrationsPresenter,
) {}
async execute(params: GetRaceRegistrationsQueryParamsDTO): Promise<void> {
async execute(
params: GetRaceRegistrationsQueryParamsDTO,
presenter: IRaceRegistrationsPresenter,
): Promise<void> {
presenter.reset();
const { raceId } = params;
const registeredDriverIds = await this.registrationRepository.getRegisteredDrivers(raceId);
this.presenter.present(registeredDriverIds);
const dto: RaceRegistrationsResultDTO = {
registeredDriverIds,
};
presenter.present(dto);
}
}

View File

@@ -8,6 +8,7 @@ import type {
RaceResultsDetailViewModel,
RaceResultsPenaltySummaryViewModel,
} from '../presenters/IRaceResultsDetailPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
import type { League } from '../../domain/entities/League';
import type { Result } from '../../domain/entities/Result';
import type { Driver } from '../../domain/entities/Driver';
@@ -18,8 +19,8 @@ export interface GetRaceResultsDetailParams {
driverId?: string;
}
function buildPointsSystem(league: League | null): Record<number, number> {
if (!league) return {};
function buildPointsSystem(league: League | null): Record<number, number> | undefined {
if (!league) return undefined;
const pointsSystems: Record<string, Record<number, number>> = {
'f1-2024': {
@@ -53,11 +54,17 @@ function buildPointsSystem(league: League | null): Record<number, number> {
},
};
return (
league.settings.customPoints ||
pointsSystems[league.settings.pointsSystem] ||
pointsSystems['f1-2024']
);
const customPoints = league.settings.customPoints;
if (customPoints) {
return customPoints;
}
const preset = pointsSystems[league.settings.pointsSystem];
if (preset) {
return preset;
}
return pointsSystems['f1-2024'];
}
function getFastestLapTime(results: Result[]): number | undefined {
@@ -73,17 +80,28 @@ function mapPenaltySummary(penalties: Penalty[]): RaceResultsPenaltySummaryViewM
}));
}
export class GetRaceResultsDetailUseCase {
export class GetRaceResultsDetailUseCase
implements
UseCase<
GetRaceResultsDetailParams,
RaceResultsDetailViewModel,
RaceResultsDetailViewModel,
IRaceResultsDetailPresenter
>
{
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly resultRepository: IResultRepository,
private readonly driverRepository: IDriverRepository,
private readonly penaltyRepository: IPenaltyRepository,
public readonly presenter: IRaceResultsDetailPresenter,
) {}
async execute(params: GetRaceResultsDetailParams): Promise<void> {
async execute(
params: GetRaceResultsDetailParams,
presenter: IRaceResultsDetailPresenter,
): Promise<void> {
presenter.reset();
const { raceId, driverId } = params;
const race = await this.raceRepository.findById(raceId);
@@ -95,11 +113,10 @@ export class GetRaceResultsDetailUseCase {
results: [],
drivers: [],
penalties: [],
pointsSystem: {},
currentDriverId: driverId,
...(driverId ? { currentDriverId: driverId } : {}),
error: 'Race not found',
};
this.presenter.present(errorViewModel);
presenter.present(errorViewModel);
return;
}
@@ -111,12 +128,12 @@ export class GetRaceResultsDetailUseCase {
]);
const effectiveCurrentDriverId =
driverId || (drivers.length > 0 ? drivers[0]!.id : undefined);
driverId ?? (drivers.length > 0 ? drivers[0]!.id : undefined);
const pointsSystem = buildPointsSystem(league as League | null);
const fastestLapTime = getFastestLapTime(results);
const penaltySummary = mapPenaltySummary(penalties);
const viewModel: RaceResultsDetailViewModel = {
race: {
id: race.id,
@@ -134,11 +151,11 @@ export class GetRaceResultsDetailUseCase {
results,
drivers,
penalties: penaltySummary,
pointsSystem,
...(pointsSystem ? { pointsSystem } : {}),
...(fastestLapTime !== undefined ? { fastestLapTime } : {}),
currentDriverId: effectiveCurrentDriverId,
...(effectiveCurrentDriverId ? { currentDriverId: effectiveCurrentDriverId } : {}),
};
this.presenter.present(viewModel);
presenter.present(viewModel);
}
}

View File

@@ -14,13 +14,16 @@ import {
AverageStrengthOfFieldCalculator,
type StrengthOfFieldCalculator,
} from '../../domain/services/StrengthOfFieldCalculator';
import type { IRaceWithSOFPresenter } from '../presenters/IRaceWithSOFPresenter';
import type { IRaceWithSOFPresenter, RaceWithSOFResultDTO } from '../presenters/IRaceWithSOFPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
export interface GetRaceWithSOFQueryParams {
raceId: string;
}
export class GetRaceWithSOFUseCase {
export class GetRaceWithSOFUseCase
implements UseCase<GetRaceWithSOFQueryParams, RaceWithSOFResultDTO, import('../presenters/IRaceWithSOFPresenter').RaceWithSOFViewModel, IRaceWithSOFPresenter>
{
private readonly sofCalculator: StrengthOfFieldCalculator;
constructor(
@@ -28,18 +31,19 @@ export class GetRaceWithSOFUseCase {
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<void> {
async execute(params: GetRaceWithSOFQueryParams, presenter: IRaceWithSOFPresenter): Promise<void> {
presenter.reset();
const { raceId } = params;
const race = await this.raceRepository.findById(raceId);
if (!race) {
return null;
return;
}
// Get participant IDs based on race status
@@ -56,30 +60,34 @@ export class GetRaceWithSOFUseCase {
// Use stored SOF if available, otherwise calculate
let strengthOfField = race.strengthOfField ?? null;
if (strengthOfField === null && participantIds.length > 0) {
const ratings = this.driverRatingProvider.getRatings(participantIds);
const driverRatings = participantIds
.filter(id => ratings.has(id))
.map(id => ({ driverId: id, rating: ratings.get(id)! }));
strengthOfField = this.sofCalculator.calculate(driverRatings);
}
this.presenter.present(
race.id,
race.leagueId,
race.scheduledAt,
race.track,
race.trackId,
race.car,
race.carId,
race.sessionType,
race.status,
presenter.reset();
const dto: RaceWithSOFResultDTO = {
raceId: race.id,
leagueId: race.leagueId,
scheduledAt: race.scheduledAt,
track: race.track ?? '',
trackId: race.trackId ?? '',
car: race.car ?? '',
carId: race.carId ?? '',
sessionType: race.sessionType,
status: race.status,
strengthOfField,
race.registeredCount ?? participantIds.length,
race.maxParticipants,
participantIds.length
);
registeredCount: race.registeredCount ?? participantIds.length,
maxParticipants: race.maxParticipants ?? participantIds.length,
participantCount: participantIds.length,
};
presenter.present(dto);
}
}

View File

@@ -1,15 +1,23 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { IRacesPagePresenter } from '@gridpilot/racing/application/presenters/IRacesPagePresenter';
import type {
IRacesPagePresenter,
RacesPageResultDTO,
RacesPageViewModel,
} from '@gridpilot/racing/application/presenters/IRacesPagePresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
export class GetRacesPageDataUseCase {
export class GetRacesPageDataUseCase
implements UseCase<void, RacesPageResultDTO, RacesPageViewModel, IRacesPagePresenter>
{
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
public readonly presenter: IRacesPagePresenter,
) {}
async execute(): Promise<void> {
async execute(_input: void, presenter: IRacesPagePresenter): Promise<void> {
presenter.reset();
const [allRaces, allLeagues] = await Promise.all([
this.raceRepository.findAll(),
this.leagueRepository.findAll(),
@@ -33,6 +41,10 @@ export class GetRacesPageDataUseCase {
isPast: race.isPast(),
}));
this.presenter.present(races);
const dto: RacesPageResultDTO = {
races,
};
presenter.present(dto);
}
}

View File

@@ -10,7 +10,11 @@ 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';
import type {
ISponsorDashboardPresenter,
SponsorDashboardViewModel,
} from '../presenters/ISponsorDashboardPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
export interface GetSponsorDashboardQueryParams {
sponsorId: string;
@@ -47,7 +51,9 @@ export interface SponsorDashboardDTO {
};
}
export class GetSponsorDashboardUseCase {
export class GetSponsorDashboardUseCase
implements UseCase<GetSponsorDashboardQueryParams, SponsorDashboardDTO | null, SponsorDashboardViewModel, ISponsorDashboardPresenter>
{
constructor(
private readonly sponsorRepository: ISponsorRepository,
private readonly seasonSponsorshipRepository: ISeasonSponsorshipRepository,
@@ -55,15 +61,19 @@ export class GetSponsorDashboardUseCase {
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly raceRepository: IRaceRepository,
private readonly presenter: ISponsorDashboardPresenter,
) {}
async execute(params: GetSponsorDashboardQueryParams): Promise<void> {
async execute(
params: GetSponsorDashboardQueryParams,
presenter: ISponsorDashboardPresenter,
): Promise<void> {
presenter.reset();
const { sponsorId } = params;
const sponsor = await this.sponsorRepository.findById(sponsorId);
if (!sponsor) {
this.presenter.present(null);
presenter.present(null);
return;
}
@@ -139,11 +149,11 @@ export class GetSponsorDashboardUseCase {
// Calculate exposure score (0-100 based on tier distribution)
const mainSponsorships = sponsorships.filter(s => s.tier === 'main').length;
const exposure = sponsorships.length > 0
const exposure = sponsorships.length > 0
? Math.min(100, (mainSponsorships * 30) + (sponsorships.length * 10))
: 0;
this.presenter.present({
const dto: SponsorDashboardDTO = {
sponsorId,
sponsorName: sponsor.name,
metrics: {
@@ -162,6 +172,8 @@ export class GetSponsorDashboardUseCase {
totalInvestment,
costPerThousandViews: Math.round(costPerThousandViews * 100) / 100,
},
});
};
presenter.present(dto);
}
}

View File

@@ -11,7 +11,11 @@ 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';
import type {
ISponsorSponsorshipsPresenter,
SponsorSponsorshipsViewModel,
} from '../presenters/ISponsorSponsorshipsPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
export interface GetSponsorSponsorshipsQueryParams {
sponsorId: string;
@@ -62,7 +66,9 @@ export interface SponsorSponsorshipsDTO {
};
}
export class GetSponsorSponsorshipsUseCase {
export class GetSponsorSponsorshipsUseCase
implements UseCase<GetSponsorSponsorshipsQueryParams, SponsorSponsorshipsDTO | null, SponsorSponsorshipsViewModel, ISponsorSponsorshipsPresenter>
{
constructor(
private readonly sponsorRepository: ISponsorRepository,
private readonly seasonSponsorshipRepository: ISeasonSponsorshipRepository,
@@ -70,15 +76,19 @@ export class GetSponsorSponsorshipsUseCase {
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly raceRepository: IRaceRepository,
private readonly presenter: ISponsorSponsorshipsPresenter,
) {}
async execute(params: GetSponsorSponsorshipsQueryParams): Promise<void> {
async execute(
params: GetSponsorSponsorshipsQueryParams,
presenter: ISponsorSponsorshipsPresenter,
): Promise<void> {
presenter.reset();
const { sponsorId } = params;
const sponsor = await this.sponsorRepository.findById(sponsorId);
if (!sponsor) {
this.presenter.present(null);
presenter.present(null);
return;
}
@@ -150,7 +160,7 @@ export class GetSponsorSponsorshipsUseCase {
const activeSponsorships = sponsorships.filter(s => s.status === 'active').length;
this.presenter.present({
const dto: SponsorSponsorshipsDTO = {
sponsorId,
sponsorName: sponsor.name,
sponsorships: sponsorshipDetails,
@@ -161,6 +171,8 @@ export class GetSponsorSponsorshipsUseCase {
totalPlatformFees,
currency: 'USD',
},
});
};
presenter.present(dto);
}
}

View File

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

View File

@@ -17,6 +17,10 @@ export class PreviewLeagueScheduleUseCase {
execute(params: PreviewLeagueScheduleQueryParams): void {
const seasonSchedule = scheduleDTOToSeasonSchedule(params.schedule);
if (!seasonSchedule) {
throw new Error('Invalid schedule data');
}
const maxRounds =
params.maxRounds && params.maxRounds > 0
? Math.min(params.maxRounds, seasonSchedule.plannedRounds)
@@ -46,8 +50,11 @@ export class PreviewLeagueScheduleUseCase {
return 'No rounds scheduled.';
}
const first = new Date(rounds[0].scheduledAt);
const last = new Date(rounds[rounds.length - 1].scheduledAt);
const firstRound = rounds[0]!;
const lastRound = rounds[rounds.length - 1]!;
const first = new Date(firstRound.scheduledAt);
const last = new Date(lastRound.scheduledAt);
const firstDate = first.toISOString().slice(0, 10);
const lastDate = last.toISOString().slice(0, 10);

View File

@@ -22,10 +22,12 @@ export class UpdateTeamUseCase {
throw new Error('Team not found');
}
const updated: Team = {
...existing,
...updates,
};
const updated = existing.update({
...(updates.name !== undefined && { name: updates.name }),
...(updates.tag !== undefined && { tag: updates.tag }),
...(updates.description !== undefined && { description: updates.description }),
...(updates.leagues !== undefined && { leagues: updates.leagues }),
});
await this.teamRepository.update(updated);
}

View File

@@ -19,9 +19,9 @@ export class Car implements IEntity<string> {
readonly carClass: CarClass;
readonly license: CarLicense;
readonly year: number;
readonly horsepower?: number;
readonly weight?: number;
readonly imageUrl?: string;
readonly horsepower: number | undefined;
readonly weight: number | undefined;
readonly imageUrl: string | undefined;
readonly gameId: string;
private constructor(props: {

View File

@@ -13,7 +13,7 @@ export class Driver implements IEntity<string> {
readonly iracingId: string;
readonly name: string;
readonly country: string;
readonly bio?: string;
readonly bio: string | undefined;
readonly joinedAt: Date;
private constructor(props: {
@@ -92,14 +92,18 @@ export class Driver implements IEntity<string> {
update(props: Partial<{
name: string;
country: string;
bio: string;
bio?: string;
}>): Driver {
const nextName = props.name ?? this.name;
const nextCountry = props.country ?? this.country;
const nextBio = props.bio ?? this.bio;
return new Driver({
id: this.id,
iracingId: this.iracingId,
name: props.name ?? this.name,
country: props.country ?? this.country,
bio: props.bio ?? this.bio,
name: nextName,
country: nextCountry,
...(nextBio !== undefined ? { bio: nextBio } : {}),
joinedAt: this.joinedAt,
});
}

View File

@@ -87,7 +87,7 @@ export class League implements IEntity<string> {
readonly ownerId: string;
readonly settings: LeagueSettings;
readonly createdAt: Date;
readonly socialLinks?: LeagueSocialLinks;
readonly socialLinks: LeagueSocialLinks | undefined;
private constructor(props: {
id: string;
@@ -140,6 +140,8 @@ export class League implements IEntity<string> {
stewarding: defaultStewardingSettings,
};
const socialLinks = props.socialLinks;
return new League({
id: props.id,
name: props.name,
@@ -147,7 +149,7 @@ export class League implements IEntity<string> {
ownerId: props.ownerId,
settings: { ...defaultSettings, ...props.settings },
createdAt: props.createdAt ?? new Date(),
socialLinks: props.socialLinks,
...(socialLinks !== undefined ? { socialLinks } : {}),
});
}
@@ -189,7 +191,7 @@ export class League implements IEntity<string> {
description: string;
ownerId: string;
settings: LeagueSettings;
socialLinks: LeagueSocialLinks | undefined;
socialLinks?: LeagueSocialLinks;
}>): League {
return new League({
id: this.id,
@@ -198,7 +200,11 @@ export class League implements IEntity<string> {
ownerId: props.ownerId ?? this.ownerId,
settings: props.settings ?? this.settings,
createdAt: this.createdAt,
socialLinks: props.socialLinks ?? this.socialLinks,
...(props.socialLinks !== undefined
? { socialLinks: props.socialLinks }
: this.socialLinks !== undefined
? { socialLinks: this.socialLinks }
: {}),
});
}
}

View File

@@ -102,12 +102,16 @@ export class Penalty implements IEntity<string> {
if (this.props.status === 'overturned') {
throw new RacingDomainInvariantError('Cannot apply an overturned penalty');
}
return new Penalty({
const base: PenaltyProps = {
...this.props,
status: 'applied',
appliedAt: new Date(),
notes,
});
};
const next: PenaltyProps =
notes !== undefined ? { ...base, notes } : base;
return Penalty.create(next);
}
/**

View File

@@ -153,14 +153,18 @@ export class Protest implements IEntity<string> {
if (!statement?.trim()) {
throw new RacingDomainValidationError('Defense statement is required');
}
const defenseBase: ProtestDefense = {
statement: statement.trim(),
submittedAt: new Date(),
};
const nextDefense: ProtestDefense =
videoUrl !== undefined ? { ...defenseBase, videoUrl } : defenseBase;
return new Protest({
...this.props,
status: 'under_review',
defense: {
statement: statement.trim(),
videoUrl,
submittedAt: new Date(),
},
defense: nextDefense,
});
}

View File

@@ -16,14 +16,14 @@ export class Race implements IEntity<string> {
readonly leagueId: string;
readonly scheduledAt: Date;
readonly track: string;
readonly trackId?: string;
readonly trackId: string | undefined;
readonly car: string;
readonly carId?: string;
readonly carId: string | undefined;
readonly sessionType: SessionType;
readonly status: RaceStatus;
readonly strengthOfField?: number;
readonly registeredCount?: number;
readonly maxParticipants?: number;
readonly strengthOfField: number | undefined;
readonly registeredCount: number | undefined;
readonly maxParticipants: number | undefined;
private constructor(props: {
id: string;
@@ -127,10 +127,34 @@ export class Race implements IEntity<string> {
throw new RacingDomainInvariantError('Only scheduled races can be started');
}
return new Race({
...this,
status: 'running',
});
const base = {
id: this.id,
leagueId: this.leagueId,
scheduledAt: this.scheduledAt,
track: this.track,
car: this.car,
sessionType: this.sessionType,
status: 'running' as RaceStatus,
};
const withTrackId =
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
const withCarId =
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
const withSof =
this.strengthOfField !== undefined
? { ...withCarId, strengthOfField: this.strengthOfField }
: withCarId;
const withRegistered =
this.registeredCount !== undefined
? { ...withSof, registeredCount: this.registeredCount }
: withSof;
const props =
this.maxParticipants !== undefined
? { ...withRegistered, maxParticipants: this.maxParticipants }
: withRegistered;
return Race.create(props);
}
/**
@@ -145,10 +169,34 @@ export class Race implements IEntity<string> {
throw new RacingDomainInvariantError('Cannot complete a cancelled race');
}
return new Race({
...this,
status: 'completed',
});
const base = {
id: this.id,
leagueId: this.leagueId,
scheduledAt: this.scheduledAt,
track: this.track,
car: this.car,
sessionType: this.sessionType,
status: 'completed' as RaceStatus,
};
const withTrackId =
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
const withCarId =
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
const withSof =
this.strengthOfField !== undefined
? { ...withCarId, strengthOfField: this.strengthOfField }
: withCarId;
const withRegistered =
this.registeredCount !== undefined
? { ...withSof, registeredCount: this.registeredCount }
: withSof;
const props =
this.maxParticipants !== undefined
? { ...withRegistered, maxParticipants: this.maxParticipants }
: withRegistered;
return Race.create(props);
}
/**
@@ -163,21 +211,62 @@ export class Race implements IEntity<string> {
throw new RacingDomainInvariantError('Race is already cancelled');
}
return new Race({
...this,
status: 'cancelled',
});
const base = {
id: this.id,
leagueId: this.leagueId,
scheduledAt: this.scheduledAt,
track: this.track,
car: this.car,
sessionType: this.sessionType,
status: 'cancelled' as RaceStatus,
};
const withTrackId =
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
const withCarId =
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
const withSof =
this.strengthOfField !== undefined
? { ...withCarId, strengthOfField: this.strengthOfField }
: withCarId;
const withRegistered =
this.registeredCount !== undefined
? { ...withSof, registeredCount: this.registeredCount }
: withSof;
const props =
this.maxParticipants !== undefined
? { ...withRegistered, maxParticipants: this.maxParticipants }
: withRegistered;
return Race.create(props);
}
/**
* Update SOF and participant count
*/
updateField(strengthOfField: number, registeredCount: number): Race {
return new Race({
...this,
const base = {
id: this.id,
leagueId: this.leagueId,
scheduledAt: this.scheduledAt,
track: this.track,
car: this.car,
sessionType: this.sessionType,
status: this.status,
strengthOfField,
registeredCount,
});
};
const withTrackId =
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
const withCarId =
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
const props =
this.maxParticipants !== undefined
? { ...withCarId, maxParticipants: this.maxParticipants }
: withCarId;
return Race.create(props);
}
/**

View File

@@ -8,11 +8,11 @@ export class Season implements IEntity<string> {
readonly leagueId: string;
readonly gameId: string;
readonly name: string;
readonly year?: number;
readonly order?: number;
readonly year: number | undefined;
readonly order: number | undefined;
readonly status: SeasonStatus;
readonly startDate?: Date;
readonly endDate?: Date;
readonly startDate: Date | undefined;
readonly endDate: Date | undefined;
private constructor(props: {
id: string;

View File

@@ -33,8 +33,8 @@ export class SeasonSponsorship implements IEntity<string> {
readonly pricing: Money;
readonly status: SponsorshipStatus;
readonly createdAt: Date;
readonly activatedAt?: Date;
readonly description?: string;
readonly activatedAt: Date | undefined;
readonly description: string | undefined;
private constructor(props: SeasonSponsorshipProps) {
this.id = props.id;
@@ -105,11 +105,23 @@ export class SeasonSponsorship implements IEntity<string> {
throw new RacingDomainInvariantError('Cannot activate a cancelled SeasonSponsorship');
}
return new SeasonSponsorship({
...this,
const base: SeasonSponsorshipProps = {
id: this.id,
seasonId: this.seasonId,
sponsorId: this.sponsorId,
tier: this.tier,
pricing: this.pricing,
status: 'active',
createdAt: this.createdAt,
activatedAt: new Date(),
});
};
const next: SeasonSponsorshipProps =
this.description !== undefined
? { ...base, description: this.description }
: base;
return new SeasonSponsorship(next);
}
/**
@@ -120,10 +132,27 @@ export class SeasonSponsorship implements IEntity<string> {
throw new RacingDomainInvariantError('SeasonSponsorship is already cancelled');
}
return new SeasonSponsorship({
...this,
const base: SeasonSponsorshipProps = {
id: this.id,
seasonId: this.seasonId,
sponsorId: this.sponsorId,
tier: this.tier,
pricing: this.pricing,
status: 'cancelled',
});
createdAt: this.createdAt,
};
const withActivated =
this.activatedAt !== undefined
? { ...base, activatedAt: this.activatedAt }
: base;
const next: SeasonSponsorshipProps =
this.description !== undefined
? { ...withActivated, description: this.description }
: withActivated;
return new SeasonSponsorship(next);
}
/**

View File

@@ -20,8 +20,8 @@ export class Sponsor implements IEntity<string> {
readonly id: string;
readonly name: string;
readonly contactEmail: string;
readonly logoUrl?: string;
readonly websiteUrl?: string;
readonly logoUrl: string | undefined;
readonly websiteUrl: string | undefined;
readonly createdAt: Date;
private constructor(props: SponsorProps) {
@@ -35,11 +35,23 @@ export class Sponsor implements IEntity<string> {
static create(props: Omit<SponsorProps, 'createdAt'> & { createdAt?: Date }): Sponsor {
this.validate(props);
return new Sponsor({
...props,
createdAt: props.createdAt ?? new Date(),
});
const { createdAt, ...rest } = props;
const base = {
id: rest.id,
name: rest.name,
contactEmail: rest.contactEmail,
createdAt: createdAt ?? new Date(),
};
const withLogo =
rest.logoUrl !== undefined ? { ...base, logoUrl: rest.logoUrl } : base;
const withWebsite =
rest.websiteUrl !== undefined
? { ...withLogo, websiteUrl: rest.websiteUrl }
: withLogo;
return new Sponsor(withWebsite);
}
private static validate(props: Omit<SponsorProps, 'createdAt'>): void {
@@ -80,18 +92,30 @@ export class Sponsor implements IEntity<string> {
update(props: Partial<{
name: string;
contactEmail: string;
logoUrl: string | undefined;
websiteUrl: string | undefined;
logoUrl?: string;
websiteUrl?: string;
}>): Sponsor {
const updated = {
const updatedBase = {
id: this.id,
name: props.name ?? this.name,
contactEmail: props.contactEmail ?? this.contactEmail,
logoUrl: props.logoUrl !== undefined ? props.logoUrl : this.logoUrl,
websiteUrl: props.websiteUrl !== undefined ? props.websiteUrl : this.websiteUrl,
createdAt: this.createdAt,
};
const withLogo =
props.logoUrl !== undefined
? { ...updatedBase, logoUrl: props.logoUrl }
: this.logoUrl !== undefined
? { ...updatedBase, logoUrl: this.logoUrl }
: updatedBase;
const updated =
props.websiteUrl !== undefined
? { ...withLogo, websiteUrl: props.websiteUrl }
: this.websiteUrl !== undefined
? { ...withLogo, websiteUrl: this.websiteUrl }
: withLogo;
Sponsor.validate(updated);
return new Sponsor(updated);
}

View File

@@ -36,12 +36,12 @@ export class SponsorshipRequest implements IEntity<string> {
readonly entityId: string;
readonly tier: SponsorshipTier;
readonly offeredAmount: Money;
readonly message?: string;
readonly message: string | undefined;
readonly status: SponsorshipRequestStatus;
readonly createdAt: Date;
readonly respondedAt?: Date;
readonly respondedBy?: string;
readonly rejectionReason?: string;
readonly respondedAt: Date | undefined;
readonly respondedBy: string | undefined;
readonly rejectionReason: string | undefined;
private constructor(props: SponsorshipRequestProps) {
this.id = props.id;
@@ -113,12 +113,28 @@ export class SponsorshipRequest implements IEntity<string> {
throw new RacingDomainValidationError('respondedBy is required when accepting');
}
return new SponsorshipRequest({
...this,
const base: SponsorshipRequestProps = {
id: this.id,
sponsorId: this.sponsorId,
entityType: this.entityType,
entityId: this.entityId,
tier: this.tier,
offeredAmount: this.offeredAmount,
status: 'accepted',
createdAt: this.createdAt,
respondedAt: new Date(),
respondedBy,
});
};
const withMessage =
this.message !== undefined ? { ...base, message: this.message } : base;
const next: SponsorshipRequestProps =
this.rejectionReason !== undefined
? { ...withMessage, rejectionReason: this.rejectionReason }
: withMessage;
return new SponsorshipRequest(next);
}
/**
@@ -133,13 +149,26 @@ export class SponsorshipRequest implements IEntity<string> {
throw new RacingDomainValidationError('respondedBy is required when rejecting');
}
return new SponsorshipRequest({
...this,
const base: SponsorshipRequestProps = {
id: this.id,
sponsorId: this.sponsorId,
entityType: this.entityType,
entityId: this.entityId,
tier: this.tier,
offeredAmount: this.offeredAmount,
status: 'rejected',
createdAt: this.createdAt,
respondedAt: new Date(),
respondedBy,
rejectionReason: reason,
});
};
const withMessage =
this.message !== undefined ? { ...base, message: this.message } : base;
const next: SponsorshipRequestProps =
reason !== undefined ? { ...withMessage, rejectionReason: reason } : withMessage;
return new SponsorshipRequest(next);
}
/**
@@ -150,11 +179,34 @@ export class SponsorshipRequest implements IEntity<string> {
throw new RacingDomainInvariantError(`Cannot withdraw a ${this.status} sponsorship request`);
}
return new SponsorshipRequest({
...this,
const base: SponsorshipRequestProps = {
id: this.id,
sponsorId: this.sponsorId,
entityType: this.entityType,
entityId: this.entityId,
tier: this.tier,
offeredAmount: this.offeredAmount,
status: 'withdrawn',
createdAt: this.createdAt,
respondedAt: new Date(),
});
};
const withRespondedBy =
this.respondedBy !== undefined
? { ...base, respondedBy: this.respondedBy }
: base;
const withMessage =
this.message !== undefined
? { ...withRespondedBy, message: this.message }
: withRespondedBy;
const next: SponsorshipRequestProps =
this.rejectionReason !== undefined
? { ...withMessage, rejectionReason: this.rejectionReason }
: withMessage;
return new SponsorshipRequest(next);
}
/**

View File

@@ -5,7 +5,7 @@
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError, RacingDomainError } from '../errors/RacingDomainError';
import { RacingDomainError, RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
export class Standing implements IEntity<string> {
@@ -104,12 +104,17 @@ export class Standing implements IEntity<string> {
*/
updatePosition(position: number): Standing {
if (!Number.isInteger(position) || position < 1) {
throw new RacingDomainError('Position must be a positive integer');
throw new RacingDomainValidationError('Position must be a positive integer');
}
return new Standing({
...this,
return Standing.create({
id: this.id,
leagueId: this.leagueId,
driverId: this.driverId,
points: this.points,
wins: this.wins,
position,
racesCompleted: this.racesCompleted,
});
}

View File

@@ -20,7 +20,7 @@ export class Track implements IEntity<string> {
readonly difficulty: TrackDifficulty;
readonly lengthKm: number;
readonly turns: number;
readonly imageUrl?: string;
readonly imageUrl: string | undefined;
readonly gameId: string;
private constructor(props: {
@@ -32,7 +32,7 @@ export class Track implements IEntity<string> {
difficulty: TrackDifficulty;
lengthKm: number;
turns: number;
imageUrl?: string;
imageUrl?: string | undefined;
gameId: string;
}) {
this.id = props.id;
@@ -64,7 +64,7 @@ export class Track implements IEntity<string> {
}): Track {
this.validate(props);
return new Track({
const base = {
id: props.id,
name: props.name,
shortName: props.shortName ?? props.name.slice(0, 3).toUpperCase(),
@@ -73,9 +73,13 @@ export class Track implements IEntity<string> {
difficulty: props.difficulty ?? 'intermediate',
lengthKm: props.lengthKm,
turns: props.turns,
imageUrl: props.imageUrl,
gameId: props.gameId,
});
};
const withImage =
props.imageUrl !== undefined ? { ...base, imageUrl: props.imageUrl } : base;
return new Track(withImage);
}
/**

View File

@@ -36,6 +36,11 @@ export interface ITeamMembershipRepository {
*/
removeMembership(teamId: string, driverId: string): Promise<void>;
/**
* Count active members for a team.
*/
countByTeamId(teamId: string): Promise<number>;
/**
* Get all join requests for a team.
*/

View File

@@ -115,6 +115,10 @@ export class EventScoringService
const sortedByLap = [...results].sort((a, b) => a.fastestLap - b.fastestLap);
const best = sortedByLap[0];
if (!best) {
return;
}
const requiresTop = rule.requiresFinishInTopN;
if (typeof requiresTop === 'number') {
if (best.position <= 0 || best.position > requiresTop) {

View File

@@ -0,0 +1,13 @@
import type { IDomainService } from '@gridpilot/shared/domain';
export interface DriverStats {
rating: number;
wins: number;
podiums: number;
totalRaces: number;
overallRank: number | null;
}
export interface IDriverStatsService extends IDomainService {
getDriverStats(driverId: string): DriverStats | null;
}

View File

@@ -0,0 +1,11 @@
import type { IDomainService } from '@gridpilot/shared/domain';
export interface DriverRanking {
driverId: string;
rating: number;
overallRank: number | null;
}
export interface IRankingService extends IDomainService {
getAllDriverRankings(): DriverRanking[];
}

View File

@@ -1,7 +1,7 @@
import { SeasonSchedule } from '../value-objects/SeasonSchedule';
import { ScheduledRaceSlot } from '../value-objects/ScheduledRaceSlot';
import type { RecurrenceStrategy } from '../value-objects/RecurrenceStrategy';
import { RacingDomainValidationError, RacingDomainError } from '../errors/RacingDomainError';
import { RacingDomainError, RacingDomainValidationError } from '../errors/RacingDomainError';
import { RaceTimeOfDay } from '../value-objects/RaceTimeOfDay';
import type { Weekday } from '../types/Weekday';
import { weekdayToIndex } from '../types/Weekday';
@@ -163,7 +163,7 @@ export class SeasonScheduleGenerator {
static generateSlotsUpTo(schedule: SeasonSchedule, maxRounds: number): ScheduledRaceSlot[] {
if (!Number.isInteger(maxRounds) || maxRounds <= 0) {
throw new RacingDomainError('maxRounds must be a positive integer');
throw new RacingDomainValidationError('maxRounds must be a positive integer');
}
const recurrence: RecurrenceStrategy = schedule.recurrence;

View File

@@ -24,7 +24,7 @@ export interface GameConstraintsProps {
/**
* Game-specific constraints for popular sim racing games
*/
const GAME_CONSTRAINTS: Record<string, GameConstraintsData> = {
const GAME_CONSTRAINTS: Record<string, GameConstraintsData> & { default: GameConstraintsData } = {
iracing: {
maxDrivers: 64,
maxTeams: 32,
@@ -76,6 +76,15 @@ const GAME_CONSTRAINTS: Record<string, GameConstraintsData> = {
},
};
function getConstraintsForId(gameId: string): GameConstraintsData {
const lower = gameId.toLowerCase();
const fromMap = GAME_CONSTRAINTS[lower];
if (fromMap) {
return fromMap;
}
return GAME_CONSTRAINTS.default;
}
export class GameConstraints implements IValueObject<GameConstraintsProps> {
readonly gameId: string;
readonly constraints: GameConstraintsData;
@@ -100,8 +109,8 @@ export class GameConstraints implements IValueObject<GameConstraintsProps> {
* Get constraints for a specific game
*/
static forGame(gameId: string): GameConstraints {
const constraints = getConstraintsForId(gameId);
const lowerId = gameId.toLowerCase();
const constraints = GAME_CONSTRAINTS[lowerId] ?? GAME_CONSTRAINTS.default;
return new GameConstraints(lowerId, constraints);
}

View File

@@ -17,17 +17,17 @@ export interface SponsorshipSlotConfig {
}
export interface SponsorshipPricingProps {
mainSlot?: SponsorshipSlotConfig;
secondarySlots?: SponsorshipSlotConfig;
mainSlot?: SponsorshipSlotConfig | undefined;
secondarySlots?: SponsorshipSlotConfig | undefined;
acceptingApplications: boolean;
customRequirements?: string;
customRequirements?: string | undefined;
}
export class SponsorshipPricing implements IValueObject<SponsorshipPricingProps> {
readonly mainSlot?: SponsorshipSlotConfig;
readonly secondarySlots?: SponsorshipSlotConfig;
readonly mainSlot: SponsorshipSlotConfig | undefined;
readonly secondarySlots: SponsorshipSlotConfig | undefined;
readonly acceptingApplications: boolean;
readonly customRequirements?: string;
readonly customRequirements: string | undefined;
private constructor(props: SponsorshipPricingProps) {
this.mainSlot = props.mainSlot;
@@ -212,8 +212,10 @@ export class SponsorshipPricing implements IValueObject<SponsorshipPricingProps>
maxSlots: 1,
};
const base = this.props;
return new SponsorshipPricing({
...this,
...base,
mainSlot: {
...currentMain,
...config,
@@ -234,8 +236,10 @@ export class SponsorshipPricing implements IValueObject<SponsorshipPricingProps>
maxSlots: 2,
};
const base = this.props;
return new SponsorshipPricing({
...this,
...base,
secondarySlots: {
...currentSecondary,
...config,
@@ -248,8 +252,10 @@ export class SponsorshipPricing implements IValueObject<SponsorshipPricingProps>
* Enable/disable accepting applications
*/
setAcceptingApplications(accepting: boolean): SponsorshipPricing {
const base = this.props;
return new SponsorshipPricing({
...this,
...base,
acceptingApplications: accepting,
});
}

View File

@@ -12,11 +12,11 @@ import type { IRaceRepository } from '@gridpilot/racing/domain/repositories/IRac
export class InMemoryResultRepository implements IResultRepository {
private results: Map<string, Result>;
private raceRepository?: IRaceRepository;
private raceRepository: IRaceRepository | null;
constructor(seedData?: Result[], raceRepository?: IRaceRepository) {
constructor(seedData?: Result[], raceRepository?: IRaceRepository | null) {
this.results = new Map();
this.raceRepository = raceRepository;
this.raceRepository = raceRepository ?? null;
if (seedData) {
seedData.forEach(result => {

View File

@@ -28,20 +28,20 @@ const POINTS_SYSTEMS: Record<string, Record<number, number>> = {
export class InMemoryStandingRepository implements IStandingRepository {
private standings: Map<string, Standing>;
private resultRepository?: IResultRepository;
private raceRepository?: IRaceRepository;
private leagueRepository?: ILeagueRepository;
private resultRepository: IResultRepository | null;
private raceRepository: IRaceRepository | null;
private leagueRepository: ILeagueRepository | null;
constructor(
seedData?: Standing[],
resultRepository?: IResultRepository,
raceRepository?: IRaceRepository,
leagueRepository?: ILeagueRepository
resultRepository?: IResultRepository | null,
raceRepository?: IRaceRepository | null,
leagueRepository?: ILeagueRepository | null
) {
this.standings = new Map();
this.resultRepository = resultRepository;
this.raceRepository = raceRepository;
this.leagueRepository = leagueRepository;
this.resultRepository = resultRepository ?? null;
this.raceRepository = raceRepository ?? null;
this.leagueRepository = leagueRepository ?? null;
if (seedData) {
seedData.forEach(standing => {
@@ -123,9 +123,16 @@ export class InMemoryStandingRepository implements IStandingRepository {
}
// Get points system
const pointsSystem = league.settings.customPoints ??
POINTS_SYSTEMS[league.settings.pointsSystem] ??
POINTS_SYSTEMS['f1-2024'];
const resolvedPointsSystem =
league.settings.customPoints ??
POINTS_SYSTEMS[league.settings.pointsSystem] ??
POINTS_SYSTEMS['f1-2024'];
if (!resolvedPointsSystem) {
throw new Error('No points system configured for league');
}
const pointsSystem: Record<number, number> = resolvedPointsSystem;
// Get all completed races for the league
const races = await this.raceRepository.findCompletedByLeagueId(leagueId);

View File

@@ -76,6 +76,11 @@ export class InMemoryTeamMembershipRepository implements ITeamMembershipReposito
return [...(this.membershipsByTeam.get(teamId) ?? [])];
}
async countByTeamId(teamId: string): Promise<number> {
const list = this.membershipsByTeam.get(teamId) ?? [];
return list.filter((m) => m.status === 'active').length;
}
async saveMembership(membership: TeamMembership): Promise<TeamMembership> {
const list = this.getMembershipList(membership.teamId);
const existingIndex = list.findIndex(

View File

@@ -1,5 +1,5 @@
{
"extends": "../../tsconfig.json",
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "dist",

View File

@@ -0,0 +1,7 @@
export function coalesce<T>(value: T | undefined | null, fallback: T): T {
return value ?? fallback;
}
export function present<T>(value: T | undefined | null): T | undefined {
return value === undefined || value === null ? undefined : value;
}

View File

@@ -1,3 +1,4 @@
export * from './Entity';
export * from './ValueObject';
export * from './Service';
export * from './Service';
export * from './Option';

View File

@@ -1,5 +1,5 @@
{
"extends": "../../tsconfig.json",
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "dist",

View File

@@ -33,17 +33,20 @@ function hashString(input: string): number {
export function getDriverAvatar(driverId: string): string {
const index = hashString(driverId) % DRIVER_AVATARS.length;
return DRIVER_AVATARS[index];
const avatar = DRIVER_AVATARS[index] ?? DRIVER_AVATARS[0];
return avatar;
}
export function getTeamLogo(teamId: string): string {
const index = hashString(teamId) % TEAM_LOGOS.length;
return TEAM_LOGOS[index];
const logo = TEAM_LOGOS[index] ?? TEAM_LOGOS[0];
return logo;
}
export function getLeagueBanner(leagueId: string): string {
const index = hashString(leagueId) % LEAGUE_BANNERS.length;
return LEAGUE_BANNERS[index];
const banner = LEAGUE_BANNERS[index] ?? LEAGUE_BANNERS[0];
return banner;
}
export interface LeagueCoverImage {

View File

@@ -81,7 +81,7 @@ export class DemoAvatarGenerationAdapter implements AvatarGenerationPort {
// For demo, return placeholder URLs based on suit color
// In production, these would be actual AI-generated images
const colorAvatars = this.placeholderAvatars[options.suitColor] ?? this.placeholderAvatars.blue;
const colorAvatars = this.getPlaceholderAvatars(options.suitColor) ?? [];
// Generate unique URLs with a hash to simulate different generations
const hash = this.generateHash((options.facePhotoUrl ?? '') + Date.now());
@@ -104,6 +104,14 @@ export class DemoAvatarGenerationAdapter implements AvatarGenerationPort {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private getPlaceholderAvatars(color: string): string[] | undefined {
const avatars = this.placeholderAvatars[color];
if (!avatars || avatars.length === 0) {
return this.placeholderAvatars.blue;
}
return avatars;
}
private generateHash(input: string): string {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {

View File

@@ -7,7 +7,8 @@ export class DemoImageServiceAdapter implements ImageServicePort {
getDriverAvatar(driverId: string): string {
const numericSuffixMatch = driverId.match(/(\d+)$/);
if (numericSuffixMatch) {
const numericSuffix = Number.parseInt(numericSuffixMatch[1], 10);
const numericSuffixString = numericSuffixMatch[1] ?? '';
const numericSuffix = Number.parseInt(numericSuffixString, 10);
return numericSuffix % 2 === 0 ? FEMALE_DEFAULT_AVATAR : MALE_DEFAULT_AVATAR;
}

View File

@@ -38,7 +38,14 @@ export class InMemoryAvatarGenerationRepository implements IAvatarGenerationRepo
async findLatestByUserId(userId: string): Promise<AvatarGenerationRequest | null> {
const userRequests = await this.findByUserId(userId);
return userRequests.length > 0 ? userRequests[0] : null;
if (userRequests.length === 0) {
return null;
}
const latest = userRequests[0];
if (!latest) {
return null;
}
return latest;
}
async delete(id: string): Promise<void> {

View File

@@ -23,11 +23,12 @@ export function createFeedEvents(
const completedRaces = races.filter((race) => race.status === 'completed');
// Focus the global feed around a stable “core” of demo drivers
const coreDrivers = faker.helpers.shuffle(drivers).slice(0, 16);
const coreDrivers = faker.helpers.shuffle(drivers).slice(0, Math.min(16, drivers.length));
coreDrivers.forEach((driver, index) => {
const league = pickOne(leagues);
const race = completedRaces[index % Math.max(1, completedRaces.length)];
const raceSource = completedRaces.length > 0 ? completedRaces : races;
const race = pickOne(raceSource);
const minutesAgo = 10 + index * 5;
const baseTimestamp = new Date(now.getTime() - minutesAgo * 60 * 1000);
@@ -166,23 +167,54 @@ export function buildFriends(
drivers: Driver[],
memberships: RacingMembership[],
): FriendDTO[] {
return drivers.map((driver) => ({
driverId: driver.id,
displayName: driver.name,
avatarUrl: getDriverAvatar(driver.id),
isOnline: true,
lastSeen: new Date(),
primaryLeagueId: memberships.find((m) => m.driverId === driver.id)?.leagueId,
primaryTeamId: memberships.find((m) => m.driverId === driver.id)?.teamId,
}));
return drivers.map((driver) => {
const membership = memberships.find((m) => m.driverId === driver.id);
const base: FriendDTO = {
driverId: driver.id,
displayName: driver.name,
avatarUrl: getDriverAvatar(driver.id),
isOnline: true,
lastSeen: new Date(),
};
const withLeague =
membership?.leagueId !== undefined
? { ...base, primaryLeagueId: membership.leagueId }
: base;
const withTeam =
membership?.teamId !== undefined
? { ...withLeague, primaryTeamId: membership.teamId }
: withLeague;
return withTeam;
});
}
/**
* Build top leagues with banner URLs for UI.
*/
export function buildTopLeagues(leagues: League[]): Array<League & { bannerUrl: string }> {
export type LeagueWithBannerDTO = {
id: string;
name: string;
description: string;
ownerId: string;
settings: League['settings'];
createdAt: Date;
socialLinks: League['socialLinks'];
bannerUrl: string;
};
export function buildTopLeagues(leagues: League[]): LeagueWithBannerDTO[] {
return leagues.map((league) => ({
...league,
id: league.id,
name: league.name,
description: league.description,
ownerId: league.ownerId,
settings: league.settings,
createdAt: league.createdAt,
socialLinks: league.socialLinks,
bannerUrl: getLeagueBanner(league.id),
}));
}
@@ -252,5 +284,9 @@ export function buildLatestResults(
* Kept here to avoid importing from core in callers that only care about feed.
*/
function pickOne<T>(items: readonly T[]): T {
return items[Math.floor(faker.number.int({ min: 0, max: items.length - 1 }))];
if (items.length === 0) {
throw new Error('pickOne: empty items array');
}
const index = faker.number.int({ min: 0, max: items.length - 1 });
return items[index]!;
}

View File

@@ -47,7 +47,11 @@ export const POINTS_TABLE: Record<number, number> = {
};
export function pickOne<T>(items: readonly T[]): T {
return items[Math.floor(faker.number.int({ min: 0, max: items.length - 1 }))];
if (items.length === 0) {
throw new Error('pickOne: empty items array');
}
const index = faker.number.int({ min: 0, max: items.length - 1 });
return items[index]!;
}
export function createDrivers(count: number): Driver[] {
@@ -136,18 +140,31 @@ export function createLeagues(ownerIds: string[]): League[] {
websiteUrl: 'https://virtual-touring.example.com',
}
: undefined;
leagues.push(
League.create({
id,
name,
description: faker.lorem.sentence(),
ownerId,
settings,
createdAt: faker.date.past(),
socialLinks,
}),
);
if (socialLinks) {
leagues.push(
League.create({
id,
name,
description: faker.lorem.sentence(),
ownerId,
settings,
createdAt: faker.date.past(),
socialLinks,
}),
);
} else {
leagues.push(
League.create({
id,
name,
description: faker.lorem.sentence(),
ownerId,
settings,
createdAt: faker.date.past(),
}),
);
}
}
return leagues;
@@ -204,11 +221,16 @@ export function createMemberships(
? pickOne(leagueTeams)
: undefined;
memberships.push({
const membership: RacingMembership = {
driverId: driver.id,
leagueId: league.id,
teamId: team?.id,
});
};
if (team) {
membership.teamId = team.id;
}
memberships.push(membership);
});
});
@@ -354,6 +376,7 @@ export function createFriendships(drivers: Driver[]): Friendship[] {
for (let offset = 1; offset <= friendCount; offset++) {
const friendIndex = (index + offset) % drivers.length;
const friend = drivers[friendIndex];
if (!friend) continue;
if (friend.id === driver.id) continue;
friendships.push({

View File

@@ -334,12 +334,14 @@ export function createSponsorshipRequests(
// Pending request: Simucube wants to sponsor a driver
if (drivers.length > 6) {
requests.push(
SponsorshipRequest.create({
id: 'req-simucube-driver-1',
sponsorId: SIMUCUBE_ID,
entityType: 'driver',
entityId: drivers[5].id,
const targetDriver = drivers[5];
if (targetDriver) {
requests.push(
SponsorshipRequest.create({
id: 'req-simucube-driver-1',
sponsorId: SIMUCUBE_ID,
entityType: 'driver',
entityId: targetDriver.id,
tier: 'main',
offeredAmount: Money.create(250, 'USD'),
message:
@@ -347,23 +349,27 @@ export function createSponsorshipRequests(
createdAt: new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000), // 2 days ago
}),
);
}
}
// Pending request: Heusinkveld wants to sponsor a team
if (teams.length > 3) {
requests.push(
SponsorshipRequest.create({
id: 'req-heusinkveld-team-1',
sponsorId: HEUSINKVELD_ID,
entityType: 'team',
entityId: teams[2].id,
tier: 'main',
offeredAmount: Money.create(550, 'USD'),
message:
'Heusinkveld pedals are known for their precision. We believe your team embodies the same values.',
createdAt: new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000), // 3 days ago
}),
);
const targetTeam = teams[2];
if (targetTeam) {
requests.push(
SponsorshipRequest.create({
id: 'req-heusinkveld-team-1',
sponsorId: HEUSINKVELD_ID,
entityType: 'team',
entityId: targetTeam.id,
tier: 'main',
offeredAmount: Money.create(550, 'USD'),
message:
'Heusinkveld pedals are known for their precision. We believe your team embodies the same values.',
createdAt: new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000), // 3 days ago
}),
);
}
}
// Pending request: Trak Racer wants to sponsor a race
@@ -403,12 +409,14 @@ export function createSponsorshipRequests(
// Already accepted request (for history)
if (teams.length > 0) {
requests.push(
SponsorshipRequest.create({
id: 'req-simlab-team-accepted',
sponsorId: SIMLAB_ID,
entityType: 'team',
entityId: teams[0].id,
const acceptedTeam = teams[0];
if (acceptedTeam) {
requests.push(
SponsorshipRequest.create({
id: 'req-simlab-team-accepted',
sponsorId: SIMLAB_ID,
entityType: 'team',
entityId: acceptedTeam.id,
tier: 'secondary',
offeredAmount: Money.create(300, 'USD'),
message: 'Sim-Lab rigs are the foundation of any competitive setup.',
@@ -416,16 +424,19 @@ export function createSponsorshipRequests(
createdAt: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), // 30 days ago
}),
);
}
}
// Already rejected request (for history)
if (drivers.length > 10) {
requests.push(
SponsorshipRequest.create({
id: 'req-motionrig-driver-rejected',
sponsorId: MOTIONRIG_ID,
entityType: 'driver',
entityId: drivers[10].id,
const rejectedDriver = drivers[10];
if (rejectedDriver) {
requests.push(
SponsorshipRequest.create({
id: 'req-motionrig-driver-rejected',
sponsorId: MOTIONRIG_ID,
entityType: 'driver',
entityId: rejectedDriver.id,
tier: 'main',
offeredAmount: Money.create(150, 'USD'),
message: 'Would you like to represent MotionRig Pro?',
@@ -433,6 +444,7 @@ export function createSponsorshipRequests(
createdAt: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), // 20 days ago
}),
);
}
}
return requests;

View File

@@ -1,5 +1,5 @@
{
"extends": "../../tsconfig.json",
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",