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

@@ -14,10 +14,10 @@ export interface LeagueScheduleDTO {
raceStartTime: string;
timezoneId: string;
recurrenceStrategy: 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';
intervalWeeks?: number;
weekdays?: Weekday[];
monthlyOrdinal?: 1 | 2 | 3 | 4;
monthlyWeekday?: Weekday;
intervalWeeks?: number | undefined;
weekdays?: Weekday[] | undefined;
monthlyOrdinal?: 1 | 2 | 3 | 4 | undefined;
monthlyWeekday?: Weekday | undefined;
plannedRounds: number;
}
@@ -54,24 +54,26 @@ export function leagueTimingsToScheduleDTO(
export function scheduleDTOToSeasonSchedule(dto: LeagueScheduleDTO): SeasonSchedule {
if (!dto.seasonStartDate) {
throw new RacingApplicationError('seasonStartDate is required');
throw new BusinessRuleViolationError('seasonStartDate is required');
}
if (!dto.raceStartTime) {
throw new RacingApplicationError('raceStartTime is required');
throw new BusinessRuleViolationError('raceStartTime is required');
}
if (!dto.timezoneId) {
throw new RacingApplicationError('timezoneId is required');
throw new BusinessRuleViolationError('timezoneId is required');
}
if (!dto.recurrenceStrategy) {
throw new RacingApplicationError('recurrenceStrategy is required');
throw new BusinessRuleViolationError('recurrenceStrategy is required');
}
if (!Number.isInteger(dto.plannedRounds) || dto.plannedRounds <= 0) {
throw new RacingApplicationError('plannedRounds must be a positive integer');
throw new BusinessRuleViolationError('plannedRounds must be a positive integer');
}
const startDate = new Date(dto.seasonStartDate);
if (Number.isNaN(startDate.getTime())) {
throw new RacingApplicationError(`seasonStartDate must be a valid date, got "${dto.seasonStartDate}"`);
throw new BusinessRuleViolationError(
`seasonStartDate must be a valid date, got "${dto.seasonStartDate}"`,
);
}
const timeOfDay = RaceTimeOfDay.fromString(dto.raceStartTime);
@@ -81,15 +83,17 @@ export function scheduleDTOToSeasonSchedule(dto: LeagueScheduleDTO): SeasonSched
if (dto.recurrenceStrategy === 'weekly') {
if (!dto.weekdays || dto.weekdays.length === 0) {
throw new RacingApplicationError('weekdays are required for weekly recurrence');
throw new BusinessRuleViolationError('weekdays are required for weekly recurrence');
}
recurrence = RecurrenceStrategyFactory.weekly(new WeekdaySet(dto.weekdays));
} else if (dto.recurrenceStrategy === 'everyNWeeks') {
if (!dto.weekdays || dto.weekdays.length === 0) {
throw new RacingApplicationError('weekdays are required for everyNWeeks recurrence');
throw new BusinessRuleViolationError('weekdays are required for everyNWeeks recurrence');
}
if (dto.intervalWeeks == null) {
throw new RacingApplicationError('intervalWeeks is required for everyNWeeks recurrence');
throw new BusinessRuleViolationError(
'intervalWeeks is required for everyNWeeks recurrence',
);
}
recurrence = RecurrenceStrategyFactory.everyNWeeks(
dto.intervalWeeks,
@@ -97,12 +101,14 @@ export function scheduleDTOToSeasonSchedule(dto: LeagueScheduleDTO): SeasonSched
);
} else if (dto.recurrenceStrategy === 'monthlyNthWeekday') {
if (!dto.monthlyOrdinal || !dto.monthlyWeekday) {
throw new RacingApplicationError('monthlyOrdinal and monthlyWeekday are required for monthlyNthWeekday');
throw new BusinessRuleViolationError(
'monthlyOrdinal and monthlyWeekday are required for monthlyNthWeekday',
);
}
const pattern = new MonthlyRecurrencePattern(dto.monthlyOrdinal, dto.monthlyWeekday);
recurrence = RecurrenceStrategyFactory.monthlyNthWeekday(pattern);
} else {
throw new RacingApplicationError(`Unknown recurrenceStrategy "${dto.recurrenceStrategy}"`);
throw new BusinessRuleViolationError(`Unknown recurrenceStrategy "${dto.recurrenceStrategy}"`);
}
return new SeasonSchedule({

View File

@@ -22,7 +22,9 @@ export type RacingEntityType =
| 'sponsorship'
| 'sponsorshipRequest'
| 'driver'
| 'membership';
| 'membership'
| 'sponsor'
| 'protest';
export interface EntityNotFoundDetails {
entity: RacingEntityType;

View File

@@ -24,13 +24,29 @@ export class EntityMappers {
iracingId: driver.iracingId,
name: driver.name,
country: driver.country,
bio: driver.bio,
bio: driver.bio ?? '',
joinedAt: driver.joinedAt.toISOString(),
};
}
static toLeagueDTO(league: League | null): LeagueDTO | null {
if (!league) return null;
const socialLinks =
league.socialLinks !== undefined
? {
...(league.socialLinks.discordUrl !== undefined
? { discordUrl: league.socialLinks.discordUrl }
: {}),
...(league.socialLinks.youtubeUrl !== undefined
? { youtubeUrl: league.socialLinks.youtubeUrl }
: {}),
...(league.socialLinks.websiteUrl !== undefined
? { websiteUrl: league.socialLinks.websiteUrl }
: {}),
}
: undefined;
return {
id: league.id,
name: league.name,
@@ -38,35 +54,37 @@ export class EntityMappers {
ownerId: league.ownerId,
settings: league.settings,
createdAt: league.createdAt.toISOString(),
socialLinks: league.socialLinks
? {
discordUrl: league.socialLinks.discordUrl,
youtubeUrl: league.socialLinks.youtubeUrl,
websiteUrl: league.socialLinks.websiteUrl,
}
: undefined,
// usedSlots is populated by capacity-aware queries, so leave undefined here
usedSlots: undefined,
...(socialLinks !== undefined ? { socialLinks } : {}),
};
}
static toLeagueDTOs(leagues: League[]): LeagueDTO[] {
return leagues.map(league => ({
id: league.id,
name: league.name,
description: league.description,
ownerId: league.ownerId,
settings: league.settings,
createdAt: league.createdAt.toISOString(),
socialLinks: league.socialLinks
? {
discordUrl: league.socialLinks.discordUrl,
youtubeUrl: league.socialLinks.youtubeUrl,
websiteUrl: league.socialLinks.websiteUrl,
}
: undefined,
usedSlots: undefined,
}));
return leagues.map((league) => {
const socialLinks =
league.socialLinks !== undefined
? {
...(league.socialLinks.discordUrl !== undefined
? { discordUrl: league.socialLinks.discordUrl }
: {}),
...(league.socialLinks.youtubeUrl !== undefined
? { youtubeUrl: league.socialLinks.youtubeUrl }
: {}),
...(league.socialLinks.websiteUrl !== undefined
? { websiteUrl: league.socialLinks.websiteUrl }
: {}),
}
: undefined;
return {
id: league.id,
name: league.name,
description: league.description,
ownerId: league.ownerId,
settings: league.settings,
createdAt: league.createdAt.toISOString(),
...(socialLinks !== undefined ? { socialLinks } : {}),
};
});
}
static toRaceDTO(race: Race | null): RaceDTO | null {
@@ -76,31 +94,43 @@ export class EntityMappers {
leagueId: race.leagueId,
scheduledAt: race.scheduledAt.toISOString(),
track: race.track,
trackId: race.trackId,
trackId: race.trackId ?? '',
car: race.car,
carId: race.carId,
carId: race.carId ?? '',
sessionType: race.sessionType,
status: race.status,
strengthOfField: race.strengthOfField,
registeredCount: race.registeredCount,
maxParticipants: race.maxParticipants,
...(race.strengthOfField !== undefined
? { strengthOfField: race.strengthOfField }
: {}),
...(race.registeredCount !== undefined
? { registeredCount: race.registeredCount }
: {}),
...(race.maxParticipants !== undefined
? { maxParticipants: race.maxParticipants }
: {}),
};
}
static toRaceDTOs(races: Race[]): RaceDTO[] {
return races.map(race => ({
return races.map((race) => ({
id: race.id,
leagueId: race.leagueId,
scheduledAt: race.scheduledAt.toISOString(),
track: race.track,
trackId: race.trackId,
trackId: race.trackId ?? '',
car: race.car,
carId: race.carId,
carId: race.carId ?? '',
sessionType: race.sessionType,
status: race.status,
strengthOfField: race.strengthOfField,
registeredCount: race.registeredCount,
maxParticipants: race.maxParticipants,
...(race.strengthOfField !== undefined
? { strengthOfField: race.strengthOfField }
: {}),
...(race.registeredCount !== undefined
? { registeredCount: race.registeredCount }
: {}),
...(race.maxParticipants !== undefined
? { maxParticipants: race.maxParticipants }
: {}),
}));
}

View File

@@ -1,4 +1,5 @@
import type { Team } from '../../domain/entities/Team';
import type { Presenter } from '@gridpilot/shared/presentation';
export interface TeamListItemViewModel {
id: string;
@@ -17,6 +18,9 @@ export interface AllTeamsViewModel {
totalCount: number;
}
export interface IAllTeamsPresenter {
present(teams: Team[]): AllTeamsViewModel;
}
export interface AllTeamsResultDTO {
teams: Array<Team & { memberCount: number }>;
}
export interface IAllTeamsPresenter
extends Presenter<AllTeamsResultDTO, AllTeamsViewModel> {}

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 DriverTeamViewModel {
team: {
@@ -22,10 +23,11 @@ export interface DriverTeamViewModel {
canManage: boolean;
}
export interface IDriverTeamPresenter {
present(
team: Team,
membership: TeamMembership,
driverId: string
): DriverTeamViewModel;
}
export interface DriverTeamResultDTO {
team: Team;
membership: TeamMembership;
driverId: string;
}
export interface IDriverTeamPresenter
extends Presenter<DriverTeamResultDTO, DriverTeamViewModel> {}

View File

@@ -31,4 +31,6 @@ export interface IDriversLeaderboardPresenter {
stats: Record<string, { rating: number; wins: number; podiums: number; totalRaces: number; overallRank: number }>,
avatarUrls: Record<string, string>
): DriversLeaderboardViewModel;
getViewModel(): DriversLeaderboardViewModel;
}

View File

@@ -37,4 +37,5 @@ export interface ILeagueDriverSeasonStatsPresenter {
driverResults: Map<string, Array<{ position: number }>>,
driverRatings: Map<string, { rating: number | null; ratingChange: number | null }>
): LeagueDriverSeasonStatsViewModel;
getViewModel(): LeagueDriverSeasonStatsViewModel;
}

View File

@@ -2,6 +2,7 @@ import type { League } from '../../domain/entities/League';
import type { Season } from '../../domain/entities/Season';
import type { LeagueScoringConfig } from '../../domain/entities/LeagueScoringConfig';
import type { Game } from '../../domain/entities/Game';
import type { Presenter } from '@gridpilot/shared/presentation';
export interface LeagueConfigFormViewModel {
leagueId: string;
@@ -49,6 +50,7 @@ export interface LeagueConfigFormViewModel {
stewardingClosesHours: number;
notifyAccusedOnProtest: boolean;
notifyOnVoteRequired: boolean;
requiredVotes?: number;
};
}
@@ -59,6 +61,5 @@ export interface LeagueFullConfigData {
game?: Game;
}
export interface ILeagueFullConfigPresenter {
present(data: LeagueFullConfigData): LeagueConfigFormViewModel;
}
export interface ILeagueFullConfigPresenter
extends Presenter<LeagueFullConfigData, LeagueConfigFormViewModel> {}

View File

@@ -1,4 +1,4 @@
import type { ChampionshipConfig } from '../../domain/value-objects/ChampionshipConfig';
import type { ChampionshipConfig } from '../../domain/types/ChampionshipConfig';
import type { LeagueScoringPresetDTO } from '../ports/LeagueScoringPresetProvider';
export interface LeagueScoringChampionshipViewModel {

View File

@@ -1,10 +1,14 @@
import type { LeagueScoringPresetDTO } from '../ports/LeagueScoringPresetProvider';
import type { Presenter } from '@gridpilot/shared/presentation';
export interface LeagueScoringPresetsViewModel {
presets: LeagueScoringPresetDTO[];
totalCount: number;
}
export interface ILeagueScoringPresetsPresenter {
present(presets: LeagueScoringPresetDTO[]): LeagueScoringPresetsViewModel;
}
export interface LeagueScoringPresetsResultDTO {
presets: LeagueScoringPresetDTO[];
}
export interface ILeagueScoringPresetsPresenter
extends Presenter<LeagueScoringPresetsResultDTO, LeagueScoringPresetsViewModel> {}

View File

@@ -1,4 +1,5 @@
import type { Standing } from '../../domain/entities/Standing';
import type { Presenter } from '@gridpilot/shared/presentation/Presenter';
export interface StandingItemViewModel {
id: string;
@@ -17,6 +18,9 @@ export interface LeagueStandingsViewModel {
standings: StandingItemViewModel[];
}
export interface ILeagueStandingsPresenter {
present(standings: Standing[]): LeagueStandingsViewModel;
}
export interface LeagueStandingsResultDTO {
standings: Standing[];
}
export interface ILeagueStandingsPresenter
extends Presenter<LeagueStandingsResultDTO, LeagueStandingsViewModel> {}

View File

@@ -1,5 +1,7 @@
import type { Presenter } from '@gridpilot/shared/presentation';
import type { GetPendingSponsorshipRequestsResultDTO } from '../use-cases/GetPendingSponsorshipRequestsUseCase';
export interface IPendingSponsorshipRequestsPresenter {
present(data: GetPendingSponsorshipRequestsResultDTO): void;
}
export type PendingSponsorshipRequestsViewModel = GetPendingSponsorshipRequestsResultDTO;
export interface IPendingSponsorshipRequestsPresenter
extends Presenter<GetPendingSponsorshipRequestsResultDTO, PendingSponsorshipRequestsViewModel> {}

View File

@@ -9,6 +9,7 @@ export interface ProfileOverviewDriverSummaryViewModel {
globalRank: number | null;
consistency: number | null;
bio: string | null;
totalDrivers: number | null;
}
export interface ProfileOverviewStatsViewModel {
@@ -23,6 +24,9 @@ export interface ProfileOverviewStatsViewModel {
winRate: number | null;
podiumRate: number | null;
percentile: number | null;
rating: number | null;
consistency: number | null;
overallRank: number | null;
}
export interface ProfileOverviewFinishDistributionViewModel {

View File

@@ -1,4 +1,5 @@
import type { PenaltyType, PenaltyStatus } from '../../domain/entities/Penalty';
import type { Presenter } from '@gridpilot/shared/presentation/Presenter';
export interface RacePenaltyViewModel {
id: string;
@@ -22,23 +23,24 @@ export interface RacePenaltiesViewModel {
penalties: RacePenaltyViewModel[];
}
export interface IRacePenaltiesPresenter {
present(
penalties: Array<{
id: string;
raceId: string;
driverId: string;
type: PenaltyType;
value?: number;
reason: string;
protestId?: string;
issuedBy: string;
status: PenaltyStatus;
issuedAt: Date;
appliedAt?: Date;
notes?: string;
getDescription(): string;
}>,
driverMap: Map<string, string>
): RacePenaltiesViewModel;
}
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;
}>;
driverMap: Map<string, string>;
}
export interface IRacePenaltiesPresenter
extends Presenter<RacePenaltiesResultDTO, RacePenaltiesViewModel> {}

View File

@@ -1,4 +1,5 @@
import type { ProtestStatus, ProtestIncident } from '../../domain/entities/Protest';
import type { Presenter } from '@gridpilot/shared/presentation/Presenter';
export interface RaceProtestViewModel {
id: string;
@@ -22,22 +23,23 @@ export interface RaceProtestsViewModel {
protests: RaceProtestViewModel[];
}
export interface IRaceProtestsPresenter {
present(
protests: Array<{
id: string;
raceId: string;
protestingDriverId: string;
accusedDriverId: string;
incident: ProtestIncident;
comment?: string;
proofVideoUrl?: string;
status: ProtestStatus;
reviewedBy?: string;
decisionNotes?: string;
filedAt: Date;
reviewedAt?: Date;
}>,
driverMap: Map<string, string>
): RaceProtestsViewModel;
}
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;
}>;
driverMap: Map<string, string>;
}
export interface IRaceProtestsPresenter
extends Presenter<RaceProtestsResultDTO, RaceProtestsViewModel> {}

View File

@@ -1,4 +1,5 @@
import type { TeamJoinRequest } from '../../domain/types/TeamMembership';
import type { Presenter } from '@gridpilot/shared/presentation';
export interface TeamJoinRequestViewModel {
requestId: string;
@@ -16,10 +17,11 @@ export interface TeamJoinRequestsViewModel {
totalCount: number;
}
export interface ITeamJoinRequestsPresenter {
present(
requests: TeamJoinRequest[],
driverNames: Record<string, string>,
avatarUrls: Record<string, string>
): TeamJoinRequestsViewModel;
}
export interface TeamJoinRequestsResultDTO {
requests: TeamJoinRequest[];
driverNames: Record<string, string>;
avatarUrls: Record<string, string>;
}
export interface ITeamJoinRequestsPresenter
extends Presenter<TeamJoinRequestsResultDTO, TeamJoinRequestsViewModel> {}

View File

@@ -1,4 +1,5 @@
import type { TeamMembership } from '../../domain/types/TeamMembership';
import type { Presenter } from '@gridpilot/shared/presentation';
export interface TeamMemberViewModel {
driverId: string;
@@ -17,10 +18,11 @@ export interface TeamMembersViewModel {
memberCount: number;
}
export interface ITeamMembersPresenter {
present(
memberships: TeamMembership[],
driverNames: Record<string, string>,
avatarUrls: Record<string, string>
): TeamMembersViewModel;
}
export interface TeamMembersResultDTO {
memberships: TeamMembership[];
driverNames: Record<string, string>;
avatarUrls: Record<string, string>;
}
export interface ITeamMembersPresenter
extends Presenter<TeamMembersResultDTO, TeamMembersViewModel> {}

View File

@@ -1,3 +1,5 @@
import type { Presenter } from '@gridpilot/shared/presentation';
export type SkillLevel = 'beginner' | 'intermediate' | 'advanced' | 'pro';
export interface TeamLeaderboardItemViewModel {
@@ -29,7 +31,10 @@ export interface TeamsLeaderboardViewModel {
topTeams: TeamLeaderboardItemViewModel[];
}
export interface ITeamsLeaderboardPresenter {
present(teams: any[], recruitingCount: number): void;
getViewModel(): TeamsLeaderboardViewModel;
}
export interface TeamsLeaderboardResultDTO {
teams: unknown[];
recruitingCount: number;
}
export interface ITeamsLeaderboardPresenter
extends Presenter<TeamsLeaderboardResultDTO, TeamsLeaderboardViewModel> {}

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);