This commit is contained in:
2025-12-11 21:06:25 +01:00
parent c49ea2598d
commit ec3ddc3a5c
227 changed files with 3496 additions and 2083 deletions

View File

@@ -56,29 +56,37 @@ export class ApplyForSponsorshipUseCase
}
if (!pricing.acceptingApplications) {
throw new RacingApplicationError('This entity is not currently accepting sponsorship applications');
throw new BusinessRuleViolationError(
'This entity is not currently accepting sponsorship applications',
);
}
// Check if the requested tier slot is available
const slotAvailable = pricing.isSlotAvailable(dto.tier);
if (!slotAvailable) {
throw new RacingApplicationError(`No ${dto.tier} sponsorship slots are available`);
throw new BusinessRuleViolationError(
`No ${dto.tier} sponsorship slots are available`,
);
}
// Check if sponsor already has a pending request for this entity
const hasPending = await this.sponsorshipRequestRepo.hasPendingRequest(
dto.sponsorId,
dto.entityType,
dto.entityId
dto.entityId,
);
if (hasPending) {
throw new RacingApplicationError('You already have a pending sponsorship request for this entity');
throw new BusinessRuleViolationError(
'You already have a pending sponsorship request for this entity',
);
}
// Validate offered amount meets minimum price
const minPrice = pricing.getPrice(dto.tier);
if (minPrice && dto.offeredAmount < minPrice.amount) {
throw new RacingApplicationError(`Offered amount must be at least ${minPrice.format()}`);
throw new BusinessRuleViolationError(
`Offered amount must be at least ${minPrice.format()}`,
);
}
// Create the sponsorship request
@@ -92,7 +100,7 @@ export class ApplyForSponsorshipUseCase
entityId: dto.entityId,
tier: dto.tier,
offeredAmount,
message: dto.message,
...(dto.message !== undefined ? { message: dto.message } : {}),
});
await this.sponsorshipRequestRepo.create(request);

View File

@@ -70,13 +70,13 @@ export class ApplyPenaltyUseCase
raceId: command.raceId,
driverId: command.driverId,
type: command.type,
value: command.value,
...(command.value !== undefined ? { value: command.value } : {}),
reason: command.reason,
protestId: command.protestId,
...(command.protestId !== undefined ? { protestId: command.protestId } : {}),
issuedBy: command.stewardId,
status: 'pending',
issuedAt: new Date(),
notes: command.notes,
...(command.notes !== undefined ? { notes: command.notes } : {}),
});
await this.penaltyRepository.create(penalty);

View File

@@ -1,5 +1,6 @@
import { v4 as uuidv4 } from 'uuid';
import { League } from '../../domain/entities/League';
import { Season } from '../../domain/entities/Season';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
@@ -70,30 +71,28 @@ export class CreateLeagueWithSeasonAndScoringUseCase
description: command.description ?? '',
ownerId: command.ownerId,
settings: {
pointsSystem: (command.scoringPresetId as any) ?? 'custom',
maxDrivers: command.maxDrivers,
// Presets are attached at scoring-config level; league settings use a stable points system id.
pointsSystem: 'custom',
...(command.maxDrivers !== undefined ? { maxDrivers: command.maxDrivers } : {}),
},
});
await this.leagueRepository.create(league);
const seasonId = uuidv4();
const season = {
const season = Season.create({
id: seasonId,
leagueId: league.id,
gameId: command.gameId,
name: `${command.name} Season 1`,
year: new Date().getFullYear(),
order: 1,
status: 'active' as const,
status: 'active',
startDate: new Date(),
endDate: new Date(),
};
});
// Season is a domain entity; use the repository's create, but shape matches Season.create expectations.
// To keep this use case independent, we rely on repository to persist the plain object.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await this.seasonRepository.create(season as any);
await this.seasonRepository.create(season);
const presetId = command.scoringPresetId ?? 'club-default';
const preset: LeagueScoringPresetDTO | undefined =

View File

@@ -55,8 +55,8 @@ export class FileProtestUseCase {
protestingDriverId: command.protestingDriverId,
accusedDriverId: command.accusedDriverId,
incident: command.incident,
comment: command.comment,
proofVideoUrl: command.proofVideoUrl,
...(command.comment !== undefined ? { comment: command.comment } : {}),
...(command.proofVideoUrl !== undefined ? { proofVideoUrl: command.proofVideoUrl } : {}),
status: 'pending',
filedAt: new Date(),
});

View File

@@ -46,9 +46,9 @@ export class GetAllLeaguesWithCapacityAndScoringUseCase
? seasons.find((s) => s.status === 'active') ?? seasons[0]
: undefined;
let scoringConfig;
let game;
let preset;
let scoringConfig: LeagueEnrichedData['scoringConfig'];
let game: LeagueEnrichedData['game'];
let preset: LeagueEnrichedData['preset'];
if (activeSeason) {
scoringConfig = await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
@@ -65,9 +65,9 @@ export class GetAllLeaguesWithCapacityAndScoringUseCase
league,
usedDriverSlots,
season: activeSeason,
scoringConfig,
game,
preset,
...(scoringConfig ?? undefined ? { scoringConfig } : {}),
...(game ?? undefined ? { game } : {}),
...(preset ?? undefined ? { preset } : {}),
});
}

View File

@@ -1,34 +1,43 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { IAllTeamsPresenter } from '../presenters/IAllTeamsPresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type {
IAllTeamsPresenter,
AllTeamsResultDTO,
} from '../presenters/IAllTeamsPresenter';
import type { UseCase } from '@gridpilot/shared/application';
import type { Team } from '../../domain/entities/Team';
/**
* Use Case for retrieving all teams.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetAllTeamsUseCase
implements AsyncUseCase<void, void> {
implements UseCase<void, AllTeamsResultDTO, import('../presenters/IAllTeamsPresenter').AllTeamsViewModel, IAllTeamsPresenter>
{
constructor(
private readonly teamRepository: ITeamRepository,
private readonly teamMembershipRepository: ITeamMembershipRepository,
public readonly presenter: IAllTeamsPresenter,
) {}
async execute(): Promise<void> {
async execute(_input: void, presenter: IAllTeamsPresenter): Promise<void> {
presenter.reset();
const teams = await this.teamRepository.findAll();
// Enrich teams with member counts
const enrichedTeams = await Promise.all(
const enrichedTeams: Array<Team & { memberCount: number }> = await Promise.all(
teams.map(async (team) => {
const memberships = await this.teamMembershipRepository.findByTeamId(team.id);
const memberCount = await this.teamMembershipRepository.countByTeamId(team.id);
return {
...team,
memberCount: memberships.length,
memberCount,
};
})
}),
);
this.presenter.present(enrichedTeams as any);
const dto: AllTeamsResultDTO = {
teams: enrichedTeams,
};
presenter.present(dto);
}
}

View File

@@ -1,32 +1,46 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { IDriverTeamPresenter } from '../presenters/IDriverTeamPresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type {
IDriverTeamPresenter,
DriverTeamResultDTO,
DriverTeamViewModel,
} from '../presenters/IDriverTeamPresenter';
import type { UseCase } from '@gridpilot/shared/application';
/**
* Use Case for retrieving a driver's team.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetDriverTeamUseCase
implements AsyncUseCase<string, boolean> {
implements UseCase<{ driverId: string }, DriverTeamResultDTO, DriverTeamViewModel, IDriverTeamPresenter>
{
constructor(
private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository,
// Kept for backward compatibility; callers must pass their own presenter.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public readonly presenter: IDriverTeamPresenter,
) {}
async execute(driverId: string): Promise<boolean> {
const membership = await this.membershipRepository.getActiveMembershipForDriver(driverId);
async execute(input: { driverId: string }, presenter: IDriverTeamPresenter): Promise<void> {
presenter.reset();
const membership = await this.membershipRepository.getActiveMembershipForDriver(input.driverId);
if (!membership) {
return false;
return;
}
const team = await this.teamRepository.findById(membership.teamId);
if (!team) {
return false;
return;
}
this.presenter.present(team, membership, driverId);
return true;
const dto: DriverTeamResultDTO = {
team,
membership,
driverId: input.driverId,
};
presenter.present(dto);
}
}

View File

@@ -79,7 +79,9 @@ export class GetEntitySponsorshipPricingUseCase
entityType: dto.entityType,
entityId: dto.entityId,
acceptingApplications: pricing.acceptingApplications,
customRequirements: pricing.customRequirements,
...(pricing.customRequirements !== undefined
? { customRequirements: pricing.customRequirements }
: {}),
};
if (pricing.mainSlot) {

View File

@@ -2,8 +2,12 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
import type { ILeagueFullConfigPresenter, LeagueFullConfigData } from '../presenters/ILeagueFullConfigPresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type {
ILeagueFullConfigPresenter,
LeagueFullConfigData,
LeagueConfigFormViewModel,
} from '../presenters/ILeagueFullConfigPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
import { EntityNotFoundError } from '../errors/RacingApplicationError';
/**
@@ -11,17 +15,16 @@ import { EntityNotFoundError } from '../errors/RacingApplicationError';
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetLeagueFullConfigUseCase
implements AsyncUseCase<{ leagueId: string }, void>
implements UseCase<{ leagueId: string }, LeagueFullConfigData, LeagueConfigFormViewModel, ILeagueFullConfigPresenter>
{
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
private readonly gameRepository: IGameRepository,
public readonly presenter: ILeagueFullConfigPresenter,
) {}
async execute(params: { leagueId: string }): Promise<void> {
async execute(params: { leagueId: string }, presenter: ILeagueFullConfigPresenter): Promise<void> {
const { leagueId } = params;
const league = await this.leagueRepository.findById(leagueId);
@@ -35,23 +38,23 @@ export class GetLeagueFullConfigUseCase
? seasons.find((s) => s.status === 'active') ?? seasons[0]
: undefined;
let scoringConfig;
let game;
if (activeSeason) {
scoringConfig = await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
if (activeSeason.gameId) {
game = await this.gameRepository.findById(activeSeason.gameId);
}
}
let scoringConfig = await (async () => {
if (!activeSeason) return undefined;
return this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
})();
let game = await (async () => {
if (!activeSeason || !activeSeason.gameId) return undefined;
return this.gameRepository.findById(activeSeason.gameId);
})();
const data: LeagueFullConfigData = {
league,
activeSeason,
scoringConfig,
game,
...(scoringConfig ?? undefined ? { scoringConfig } : {}),
...(game ?? undefined ? { game } : {}),
};
this.presenter.present(data);
presenter.reset();
presenter.present(data);
}
}

View File

@@ -33,10 +33,14 @@ export class GetLeagueScoringConfigUseCase
if (!seasons || seasons.length === 0) {
throw new Error(`No seasons found for league ${leagueId}`);
}
const activeSeason =
seasons.find((s) => s.status === 'active') ?? seasons[0];
if (!activeSeason) {
throw new Error(`No active season could be determined for league ${leagueId}`);
}
const scoringConfig =
await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
if (!scoringConfig) {
@@ -50,14 +54,14 @@ export class GetLeagueScoringConfigUseCase
const presetId = scoringConfig.scoringPresetId;
const preset = presetId ? this.presetProvider.getPresetById(presetId) : undefined;
const data: LeagueScoringConfigData = {
leagueId: league.id,
seasonId: activeSeason.id,
gameId: game.id,
gameName: game.name,
scoringPresetId: presetId,
preset,
...(presetId !== undefined ? { scoringPresetId: presetId } : {}),
...(preset !== undefined ? { preset } : {}),
championships: scoringConfig.championships,
};

View File

@@ -1,6 +1,10 @@
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { ILeagueStandingsPresenter } from '../presenters/ILeagueStandingsPresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type {
ILeagueStandingsPresenter,
LeagueStandingsResultDTO,
LeagueStandingsViewModel,
} from '../presenters/ILeagueStandingsPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
export interface GetLeagueStandingsUseCaseParams {
leagueId: string;
@@ -11,14 +15,20 @@ export interface GetLeagueStandingsUseCaseParams {
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetLeagueStandingsUseCase
implements AsyncUseCase<GetLeagueStandingsUseCaseParams, void> {
constructor(
private readonly standingRepository: IStandingRepository,
public readonly presenter: ILeagueStandingsPresenter,
) {}
implements
UseCase<GetLeagueStandingsUseCaseParams, LeagueStandingsResultDTO, LeagueStandingsViewModel, ILeagueStandingsPresenter>
{
constructor(private readonly standingRepository: IStandingRepository) {}
async execute(params: GetLeagueStandingsUseCaseParams): Promise<void> {
async execute(
params: GetLeagueStandingsUseCaseParams,
presenter: ILeagueStandingsPresenter,
): Promise<void> {
const standings = await this.standingRepository.findByLeagueId(params.leagueId);
this.presenter.present(standings);
const dto: LeagueStandingsResultDTO = {
standings,
};
presenter.reset();
presenter.present(dto);
}
}

View File

@@ -8,7 +8,11 @@ import type { ISponsorshipRequestRepository } from '../../domain/repositories/IS
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship';
import type { IPendingSponsorshipRequestsPresenter } from '../presenters/IPendingSponsorshipRequestsPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
import type {
IPendingSponsorshipRequestsPresenter,
PendingSponsorshipRequestsViewModel,
} from '../presenters/IPendingSponsorshipRequestsPresenter';
export interface GetPendingSponsorshipRequestsDTO {
entityType: SponsorableEntityType;
@@ -37,14 +41,23 @@ export interface GetPendingSponsorshipRequestsResultDTO {
totalCount: number;
}
export class GetPendingSponsorshipRequestsUseCase {
export class GetPendingSponsorshipRequestsUseCase
implements UseCase<
GetPendingSponsorshipRequestsDTO,
GetPendingSponsorshipRequestsResultDTO,
PendingSponsorshipRequestsViewModel,
IPendingSponsorshipRequestsPresenter
> {
constructor(
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly sponsorRepo: ISponsorRepository,
private readonly presenter: IPendingSponsorshipRequestsPresenter,
) {}
async execute(dto: GetPendingSponsorshipRequestsDTO): Promise<void> {
async execute(
dto: GetPendingSponsorshipRequestsDTO,
presenter: IPendingSponsorshipRequestsPresenter,
): Promise<void> {
presenter.reset();
const requests = await this.sponsorshipRequestRepo.findPendingByEntity(
dto.entityType,
dto.entityId
@@ -59,12 +72,12 @@ export class GetPendingSponsorshipRequestsUseCase {
id: request.id,
sponsorId: request.sponsorId,
sponsorName: sponsor?.name ?? 'Unknown Sponsor',
sponsorLogo: sponsor?.logoUrl,
...(sponsor?.logoUrl !== undefined ? { sponsorLogo: sponsor.logoUrl } : {}),
tier: request.tier,
offeredAmount: request.offeredAmount.amount,
currency: request.offeredAmount.currency,
formattedAmount: request.offeredAmount.format(),
message: request.message,
...(request.message !== undefined ? { message: request.message } : {}),
createdAt: request.createdAt,
platformFee: request.getPlatformFee().amount,
netAmount: request.getNetAmount().amount,
@@ -74,7 +87,7 @@ export class GetPendingSponsorshipRequestsUseCase {
// Sort by creation date (newest first)
requestDTOs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
this.presenter.present({
presenter.present({
entityType: dto.entityType,
entityId: dto.entityId,
requests: requestDTOs,

View File

@@ -50,7 +50,7 @@ export class GetProfileOverviewUseCase {
public readonly presenter: IProfileOverviewPresenter,
) {}
async execute(params: GetProfileOverviewParams): Promise<void> {
async execute(params: GetProfileOverviewParams): Promise<ProfileOverviewViewModel | null> {
const { driverId } = params;
const driver = await this.driverRepository.findById(driverId);
@@ -69,7 +69,7 @@ export class GetProfileOverviewUseCase {
};
this.presenter.present(emptyViewModel);
return;
return emptyViewModel;
}
const [statsAdapter, teams, friends] = await Promise.all([
@@ -95,6 +95,7 @@ export class GetProfileOverviewUseCase {
};
this.presenter.present(viewModel);
return viewModel;
}
private buildDriverSummary(
@@ -103,6 +104,7 @@ export class GetProfileOverviewUseCase {
): ProfileOverviewDriverSummaryViewModel {
const rankings = this.getAllDriverRankings();
const fallbackRank = this.computeFallbackRank(driver.id, rankings);
const totalDrivers = rankings.length;
return {
id: driver.id,
@@ -110,13 +112,15 @@ export class GetProfileOverviewUseCase {
country: driver.country,
avatarUrl: this.imageService.getDriverAvatar(driver.id),
iracingId: driver.iracingId ?? null,
joinedAt: driver.joinedAt instanceof Date
? driver.joinedAt.toISOString()
: new Date(driver.joinedAt).toISOString(),
joinedAt:
driver.joinedAt instanceof Date
? driver.joinedAt.toISOString()
: new Date(driver.joinedAt).toISOString(),
rating: stats?.rating ?? null,
globalRank: stats?.overallRank ?? fallbackRank,
consistency: stats?.consistency ?? null,
bio: driver.bio ?? null,
totalDrivers,
};
}
@@ -161,6 +165,9 @@ export class GetProfileOverviewUseCase {
winRate,
podiumRate,
percentile: stats.percentile,
rating: stats.rating,
consistency: stats.consistency,
overallRank: stats.overallRank,
};
}
@@ -417,8 +424,10 @@ export class GetProfileOverviewUseCase {
'Flexible schedule',
];
const socialHandles = socialOptions[hash % socialOptions.length];
const achievementsSource = achievementSets[hash % achievementSets.length];
const socialHandles =
socialOptions[hash % socialOptions.length] ?? [];
const achievementsSource =
achievementSets[hash % achievementSets.length] ?? [];
return {
socialHandles,
@@ -430,11 +439,11 @@ export class GetProfileOverviewUseCase {
rarity: achievement.rarity,
earnedAt: achievement.earnedAt.toISOString(),
})),
racingStyle: styles[hash % styles.length],
favoriteTrack: tracks[hash % tracks.length],
favoriteCar: cars[hash % cars.length],
timezone: timezones[hash % timezones.length],
availableHours: hours[hash % hours.length],
racingStyle: styles[hash % styles.length] ?? 'Consistent Pacer',
favoriteTrack: tracks[hash % tracks.length] ?? 'Unknown Track',
favoriteCar: cars[hash % cars.length] ?? 'Unknown Car',
timezone: timezones[hash % timezones.length] ?? 'UTC',
availableHours: hours[hash % hours.length] ?? 'Flexible schedule',
lookingForTeam: hash % 3 === 0,
openToRequests: hash % 2 === 0,
};

View File

@@ -7,36 +7,51 @@
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IRacePenaltiesPresenter } from '../presenters/IRacePenaltiesPresenter';
import type {
IRacePenaltiesPresenter,
RacePenaltiesResultDTO,
RacePenaltiesViewModel,
} from '../presenters/IRacePenaltiesPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
export class GetRacePenaltiesUseCase {
export interface GetRacePenaltiesInput {
raceId: string;
}
export class GetRacePenaltiesUseCase
implements
UseCase<GetRacePenaltiesInput, RacePenaltiesResultDTO, RacePenaltiesViewModel, IRacePenaltiesPresenter>
{
constructor(
private readonly penaltyRepository: IPenaltyRepository,
private readonly driverRepository: IDriverRepository,
public readonly presenter: IRacePenaltiesPresenter,
) {}
async execute(raceId: string): Promise<void> {
const penalties = await this.penaltyRepository.findByRaceId(raceId);
// Load all driver details in parallel
async execute(input: GetRacePenaltiesInput, presenter: IRacePenaltiesPresenter): Promise<void> {
const penalties = await this.penaltyRepository.findByRaceId(input.raceId);
const driverIds = new Set<string>();
penalties.forEach(penalty => {
penalties.forEach((penalty) => {
driverIds.add(penalty.driverId);
driverIds.add(penalty.issuedBy);
});
const drivers = await Promise.all(
Array.from(driverIds).map(id => this.driverRepository.findById(id))
Array.from(driverIds).map((id) => this.driverRepository.findById(id)),
);
const driverMap = new Map<string, string>();
drivers.forEach(driver => {
drivers.forEach((driver) => {
if (driver) {
driverMap.set(driver.id, driver.name);
}
});
this.presenter.present(penalties, driverMap);
presenter.reset();
const dto: RacePenaltiesResultDTO = {
penalties,
driverMap,
};
presenter.present(dto);
}
}

View File

@@ -7,21 +7,31 @@
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IRaceProtestsPresenter } from '../presenters/IRaceProtestsPresenter';
import type {
IRaceProtestsPresenter,
RaceProtestsResultDTO,
RaceProtestsViewModel,
} from '../presenters/IRaceProtestsPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
export class GetRaceProtestsUseCase {
export interface GetRaceProtestsInput {
raceId: string;
}
export class GetRaceProtestsUseCase
implements
UseCase<GetRaceProtestsInput, RaceProtestsResultDTO, RaceProtestsViewModel, IRaceProtestsPresenter>
{
constructor(
private readonly protestRepository: IProtestRepository,
private readonly driverRepository: IDriverRepository,
public readonly presenter: IRaceProtestsPresenter,
) {}
async execute(raceId: string): Promise<void> {
const protests = await this.protestRepository.findByRaceId(raceId);
// Load all driver details in parallel
async execute(input: GetRaceProtestsInput, presenter: IRaceProtestsPresenter): Promise<void> {
const protests = await this.protestRepository.findByRaceId(input.raceId);
const driverIds = new Set<string>();
protests.forEach(protest => {
protests.forEach((protest) => {
driverIds.add(protest.protestingDriverId);
driverIds.add(protest.accusedDriverId);
if (protest.reviewedBy) {
@@ -30,16 +40,21 @@ export class GetRaceProtestsUseCase {
});
const drivers = await Promise.all(
Array.from(driverIds).map(id => this.driverRepository.findById(id))
Array.from(driverIds).map((id) => this.driverRepository.findById(id)),
);
const driverMap = new Map<string, string>();
drivers.forEach(driver => {
drivers.forEach((driver) => {
if (driver) {
driverMap.set(driver.id, driver.name);
}
});
this.presenter.present(protests, driverMap);
presenter.reset();
const dto: RaceProtestsResultDTO = {
protests,
driverMap,
};
presenter.present(dto);
}
}

View File

@@ -69,7 +69,7 @@ function mapPenaltySummary(penalties: Penalty[]): RaceResultsPenaltySummaryViewM
return penalties.map((p) => ({
driverId: p.driverId,
type: p.type,
value: p.value,
...(p.value !== undefined ? { value: p.value } : {}),
}));
}
@@ -96,7 +96,6 @@ export class GetRaceResultsDetailUseCase {
drivers: [],
penalties: [],
pointsSystem: {},
fastestLapTime: undefined,
currentDriverId: driverId,
error: 'Race not found',
};
@@ -117,7 +116,7 @@ export class GetRaceResultsDetailUseCase {
const pointsSystem = buildPointsSystem(league as League | null);
const fastestLapTime = getFastestLapTime(results);
const penaltySummary = mapPenaltySummary(penalties);
const viewModel: RaceResultsDetailViewModel = {
race: {
id: race.id,
@@ -136,7 +135,7 @@ export class GetRaceResultsDetailUseCase {
drivers,
penalties: penaltySummary,
pointsSystem,
fastestLapTime,
...(fastestLapTime !== undefined ? { fastestLapTime } : {}),
currentDriverId: effectiveCurrentDriverId,
};

View File

@@ -121,8 +121,8 @@ export class GetSponsorSponsorshipsUseCase {
leagueName: league.name,
seasonId: season.id,
seasonName: season.name,
seasonStartDate: season.startDate,
seasonEndDate: season.endDate,
...(season.startDate !== undefined ? { seasonStartDate: season.startDate } : {}),
...(season.endDate !== undefined ? { seasonEndDate: season.endDate } : {}),
tier: sponsorship.tier,
status: sponsorship.status,
pricing: {
@@ -144,7 +144,7 @@ export class GetSponsorSponsorshipsUseCase {
impressions,
},
createdAt: sponsorship.createdAt,
activatedAt: sponsorship.activatedAt,
...(sponsorship.activatedAt !== undefined ? { activatedAt: sponsorship.activatedAt } : {}),
});
}

View File

@@ -1,26 +1,37 @@
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IImageServicePort } from '../ports/IImageServicePort';
import type { ITeamJoinRequestsPresenter } from '../presenters/ITeamJoinRequestsPresenter';
import type {
ITeamJoinRequestsPresenter,
TeamJoinRequestsResultDTO,
TeamJoinRequestsViewModel,
} from '../presenters/ITeamJoinRequestsPresenter';
import type { UseCase } from '@gridpilot/shared/application';
/**
* Use Case for retrieving team join requests.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetTeamJoinRequestsUseCase {
export class GetTeamJoinRequestsUseCase
implements UseCase<{ teamId: string }, TeamJoinRequestsResultDTO, TeamJoinRequestsViewModel, ITeamJoinRequestsPresenter>
{
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
private readonly driverRepository: IDriverRepository,
private readonly imageService: IImageServicePort,
// Kept for backward compatibility; callers must pass their own presenter.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public readonly presenter: ITeamJoinRequestsPresenter,
) {}
async execute(teamId: string): Promise<void> {
const requests = await this.membershipRepository.getJoinRequests(teamId);
async execute(input: { teamId: string }, presenter: ITeamJoinRequestsPresenter): Promise<void> {
presenter.reset();
const requests = await this.membershipRepository.getJoinRequests(input.teamId);
const driverNames: Record<string, string> = {};
const avatarUrls: Record<string, string> = {};
for (const request of requests) {
const driver = await this.driverRepository.findById(request.driverId);
if (driver) {
@@ -28,7 +39,13 @@ export class GetTeamJoinRequestsUseCase {
}
avatarUrls[request.driverId] = this.imageService.getDriverAvatar(request.driverId);
}
this.presenter.present(requests, driverNames, avatarUrls);
const dto: TeamJoinRequestsResultDTO = {
requests,
driverNames,
avatarUrls,
};
presenter.present(dto);
}
}

View File

@@ -1,26 +1,37 @@
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IImageServicePort } from '../ports/IImageServicePort';
import type { ITeamMembersPresenter } from '../presenters/ITeamMembersPresenter';
import type {
ITeamMembersPresenter,
TeamMembersResultDTO,
TeamMembersViewModel,
} from '../presenters/ITeamMembersPresenter';
import type { UseCase } from '@gridpilot/shared/application';
/**
* Use Case for retrieving team members.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetTeamMembersUseCase {
export class GetTeamMembersUseCase
implements UseCase<{ teamId: string }, TeamMembersResultDTO, TeamMembersViewModel, ITeamMembersPresenter>
{
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
private readonly driverRepository: IDriverRepository,
private readonly imageService: IImageServicePort,
// Kept for backward compatibility; callers must pass their own presenter.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public readonly presenter: ITeamMembersPresenter,
) {}
async execute(teamId: string): Promise<void> {
const memberships = await this.membershipRepository.getTeamMembers(teamId);
async execute(input: { teamId: string }, presenter: ITeamMembersPresenter): Promise<void> {
presenter.reset();
const memberships = await this.membershipRepository.getTeamMembers(input.teamId);
const driverNames: Record<string, string> = {};
const avatarUrls: Record<string, string> = {};
for (const membership of memberships) {
const driver = await this.driverRepository.findById(membership.driverId);
if (driver) {
@@ -28,7 +39,13 @@ export class GetTeamMembersUseCase {
}
avatarUrls[membership.driverId] = this.imageService.getDriverAvatar(membership.driverId);
}
this.presenter.present(memberships, driverNames, avatarUrls);
const dto: TeamMembersResultDTO = {
memberships,
driverNames,
avatarUrls,
};
presenter.present(dto);
}
}

View File

@@ -1,8 +1,13 @@
import type { ITeamRepository } from '@gridpilot/racing/domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '@gridpilot/racing/domain/repositories/ITeamMembershipRepository';
import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository';
import type { ITeamsLeaderboardPresenter } from '@gridpilot/racing/application/presenters/ITeamsLeaderboardPresenter';
import type {
ITeamsLeaderboardPresenter,
TeamsLeaderboardResultDTO,
TeamsLeaderboardViewModel,
} from '@gridpilot/racing/application/presenters/ITeamsLeaderboardPresenter';
import { SkillLevelService } from '@gridpilot/racing/domain/services/SkillLevelService';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
interface DriverStatsAdapter {
rating: number | null;
@@ -16,22 +21,22 @@ interface DriverStatsAdapter {
* Plain constructor-injected dependencies (no decorators) to keep the
* application layer framework-agnostic and compatible with test tooling.
*/
export class GetTeamsLeaderboardUseCase {
export class GetTeamsLeaderboardUseCase
implements UseCase<void, TeamsLeaderboardResultDTO, TeamsLeaderboardViewModel, ITeamsLeaderboardPresenter> {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly teamMembershipRepository: ITeamMembershipRepository,
private readonly driverRepository: IDriverRepository,
private readonly getDriverStats: (driverId: string) => DriverStatsAdapter | null,
public readonly presenter: ITeamsLeaderboardPresenter,
) {}
async execute(): Promise<void> {
async execute(_input: void, presenter: ITeamsLeaderboardPresenter): Promise<void> {
const allTeams = await this.teamRepository.findAll();
const teams: any[] = [];
await Promise.all(
allTeams.map(async (team) => {
const memberships = await this.teamMembershipRepository.findByTeamId(team.id);
const memberships = await this.teamMembershipRepository.getTeamMembers(team.id);
const memberCount = memberships.length;
let ratingSum = 0;
@@ -66,15 +71,18 @@ export class GetTeamsLeaderboardUseCase {
isRecruiting: true,
createdAt: new Date(),
description: team.description,
specialization: team.specialization as 'endurance' | 'sprint' | 'mixed' | undefined,
region: team.region,
languages: team.languages,
});
})
);
const recruitingCount = teams.filter((t) => t.isRecruiting).length;
this.presenter.present(teams, recruitingCount);
const result: TeamsLeaderboardResultDTO = {
teams,
recruitingCount,
};
presenter.reset();
presenter.present(result);
}
}

View File

@@ -1,18 +1,28 @@
import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider';
import type { ILeagueScoringPresetsPresenter } from '../presenters/ILeagueScoringPresetsPresenter';
import type {
ILeagueScoringPresetsPresenter,
LeagueScoringPresetsResultDTO,
LeagueScoringPresetsViewModel,
} from '../presenters/ILeagueScoringPresetsPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
/**
* Use Case for listing league scoring presets.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class ListLeagueScoringPresetsUseCase {
constructor(
private readonly presetProvider: LeagueScoringPresetProvider,
public readonly presenter: ILeagueScoringPresetsPresenter,
) {}
export class ListLeagueScoringPresetsUseCase
implements UseCase<void, LeagueScoringPresetsResultDTO, LeagueScoringPresetsViewModel, ILeagueScoringPresetsPresenter>
{
constructor(private readonly presetProvider: LeagueScoringPresetProvider) {}
async execute(): Promise<void> {
async execute(_input: void, presenter: ILeagueScoringPresetsPresenter): Promise<void> {
const presets = await this.presetProvider.listPresets();
this.presenter.present(presets);
const dto: LeagueScoringPresetsResultDTO = {
presets,
};
presenter.reset();
presenter.present(dto);
}
}

View File

@@ -38,14 +38,16 @@ export class RejectSponsorshipRequestUseCase {
// Reject the request
const rejectedRequest = request.reject(dto.respondedBy, dto.reason);
await this.sponsorshipRequestRepo.update(rejectedRequest);
// TODO: In a real implementation, notify the sponsor
return {
requestId: rejectedRequest.id,
status: 'rejected',
rejectedAt: rejectedRequest.respondedAt!,
reason: rejectedRequest.rejectionReason,
...(rejectedRequest.rejectionReason !== undefined
? { reason: rejectedRequest.rejectionReason }
: {}),
};
}
}

View File

@@ -24,8 +24,8 @@ export class UpdateDriverProfileUseCase {
}
const updated = existing.update({
bio: bio ?? existing.bio,
country: country ?? existing.country,
...(bio !== undefined ? { bio } : {}),
...(country !== undefined ? { country } : {}),
});
const persisted = await this.driverRepository.update(updated);