rename to core

This commit is contained in:
2025-12-15 13:46:07 +01:00
parent aedf58643d
commit 5c22f8820c
559 changed files with 415 additions and 767 deletions

View File

@@ -0,0 +1,16 @@
import type { ParticipantRef } from '@gridpilot/racing/domain/types/ParticipantRef';
export interface ChampionshipStandingsRowDTO {
participant: ParticipantRef;
position: number;
totalPoints: number;
resultsCounted: number;
resultsDropped: number;
}
export interface ChampionshipStandingsDTO {
seasonId: string;
championshipId: string;
championshipName: string;
rows: ChampionshipStandingsRowDTO[];
}

View File

@@ -0,0 +1,13 @@
import type { Team } from '../../domain/entities/Team';
export interface CreateTeamCommandDTO {
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
}
export interface CreateTeamResultDTO {
team: Team;
}

View File

@@ -0,0 +1,8 @@
export type DriverDTO = {
id: string;
iracingId: string;
name: string;
country: string;
bio?: string;
joinedAt: string;
};

View File

@@ -0,0 +1,4 @@
export interface JoinLeagueCommandDTO {
leagueId: string;
driverId: string;
}

View File

@@ -0,0 +1,146 @@
import type { LeagueVisibilityType } from '../../domain/value-objects/LeagueVisibility';
import type { StewardingDecisionMode } from '../../domain/entities/League';
export type LeagueStructureMode = 'solo' | 'fixedTeams';
/**
* League visibility determines public visibility and ranking status.
* - 'ranked': Public, competitive, affects driver ratings. Requires min 10 drivers.
* - 'unranked': Private, casual with friends. No rating impact. Any number of drivers.
*
* For backward compatibility, 'public'/'private' are also supported in the form,
* but the domain uses 'ranked'/'unranked'.
*/
export type LeagueVisibilityFormValue = LeagueVisibilityType | 'public' | 'private';
export interface LeagueStructureFormDTO {
mode: LeagueStructureMode;
maxDrivers: number;
maxTeams?: number;
driversPerTeam?: number;
multiClassEnabled?: boolean;
}
export interface LeagueChampionshipsFormDTO {
enableDriverChampionship: boolean;
enableTeamChampionship: boolean;
enableNationsChampionship: boolean;
enableTrophyChampionship: boolean;
}
export interface LeagueScoringFormDTO {
patternId?: string; // e.g. 'sprint-main-driver', 'club-ladder-solo'
// For now, keep customScoring optional and simple:
customScoringEnabled?: boolean;
}
export interface LeagueDropPolicyFormDTO {
strategy: 'none' | 'bestNResults' | 'dropWorstN';
n?: number;
}
export interface LeagueTimingsFormDTO {
practiceMinutes?: number;
qualifyingMinutes: number;
sprintRaceMinutes?: number;
mainRaceMinutes: number;
sessionCount: number;
roundsPlanned?: number;
seasonStartDate?: string; // ISO date YYYY-MM-DD
seasonEndDate?: string; // ISO date YYYY-MM-DD
raceStartTime?: string; // "HH:MM" 24h
timezoneId?: string; // IANA ID, e.g. "Europe/Berlin", or "track" for track local time
recurrenceStrategy?: 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';
intervalWeeks?: number;
weekdays?: import('../../domain/types/Weekday').Weekday[];
monthlyOrdinal?: 1 | 2 | 3 | 4;
monthlyWeekday?: import('../../domain/types/Weekday').Weekday;
}
/**
* Stewarding configuration for protests and penalties.
*/
export interface LeagueStewardingFormDTO {
/**
* How protest decisions are made
*/
decisionMode: StewardingDecisionMode;
/**
* Number of votes required to uphold/reject a protest
* Used with steward_vote, member_vote, steward_veto, member_veto modes
*/
requiredVotes?: number;
/**
* Whether to require a defense from the accused before deciding
*/
requireDefense: boolean;
/**
* Time limit (hours) for accused to submit defense
*/
defenseTimeLimit: number;
/**
* Time limit (hours) for voting to complete
*/
voteTimeLimit: number;
/**
* Time limit (hours) after race ends when protests can be filed
*/
protestDeadlineHours: number;
/**
* Time limit (hours) after race ends when stewarding is closed
*/
stewardingClosesHours: number;
/**
* Whether to notify the accused when a protest is filed
*/
notifyAccusedOnProtest: boolean;
/**
* Whether to notify eligible voters when a vote is required
*/
notifyOnVoteRequired: boolean;
}
export interface LeagueConfigFormModel {
leagueId?: string; // present for admin, omitted for create
basics: {
name: string;
description?: string;
/**
* League visibility/ranking mode.
* - 'ranked' (or legacy 'public'): Competitive, public, affects ratings. Min 10 drivers.
* - 'unranked' (or legacy 'private'): Casual with friends, no rating impact.
*/
visibility: LeagueVisibilityFormValue;
gameId: string;
/**
* League logo as base64 data URL (optional).
* Format: data:image/png;base64,... or data:image/jpeg;base64,...
*/
logoDataUrl?: string;
};
structure: LeagueStructureFormDTO;
championships: LeagueChampionshipsFormDTO;
scoring: LeagueScoringFormDTO;
dropPolicy: LeagueDropPolicyFormDTO;
timings: LeagueTimingsFormDTO;
stewarding: LeagueStewardingFormDTO;
}
/**
* Helper to normalize visibility values to new terminology.
* Maps 'public' -> 'ranked' and 'private' -> 'unranked'.
*/
export function normalizeVisibility(value: LeagueVisibilityFormValue): LeagueVisibilityType {
if (value === 'public' || value === 'ranked') return 'ranked';
return 'unranked';
}
/**
* Helper to convert new terminology to legacy for backward compatibility.
* Maps 'ranked' -> 'public' and 'unranked' -> 'private'.
*/
export function toLegacyVisibility(value: LeagueVisibilityFormValue): 'public' | 'private' {
if (value === 'ranked' || value === 'public') return 'public';
return 'private';
}

View File

@@ -0,0 +1,24 @@
export type LeagueDTO = {
id: string;
name: string;
description: string;
ownerId: string;
settings: {
pointsSystem: 'f1-2024' | 'indycar' | 'custom';
sessionDuration?: number;
qualifyingFormat?: 'single-lap' | 'open';
customPoints?: Record<number, number>;
maxDrivers?: number;
};
createdAt: string;
socialLinks?: {
discordUrl?: string;
youtubeUrl?: string;
websiteUrl?: string;
};
/**
* Number of active driver slots currently used in this league.
* Populated by capacity-aware queries such as GetAllLeaguesWithCapacityQuery.
*/
usedSlots?: number;
};

View File

@@ -0,0 +1,20 @@
export type LeagueDriverSeasonStatsDTO = {
leagueId: string;
driverId: string;
position: number;
driverName: string;
teamId?: string;
teamName?: string;
totalPoints: number;
basePoints: number;
penaltyPoints: number;
bonusPoints: number;
pointsPerRace: number;
racesStarted: number;
racesFinished: number;
dnfs: number;
noShows: number;
avgFinish: number | null;
rating: number | null;
ratingChange: number | null;
};

View File

@@ -0,0 +1,121 @@
import type { LeagueTimingsFormDTO } from './LeagueConfigFormDTO';
import type { Weekday } from '../../domain/types/Weekday';
import { RaceTimeOfDay } from '../../domain/value-objects/RaceTimeOfDay';
import { LeagueTimezone } from '../../domain/value-objects/LeagueTimezone';
import { WeekdaySet } from '../../domain/value-objects/WeekdaySet';
import { MonthlyRecurrencePattern } from '../../domain/value-objects/MonthlyRecurrencePattern';
import type { RecurrenceStrategy } from '../../domain/value-objects/RecurrenceStrategy';
import { RecurrenceStrategyFactory } from '../../domain/value-objects/RecurrenceStrategy';
import { SeasonSchedule } from '../../domain/value-objects/SeasonSchedule';
import { BusinessRuleViolationError } from '../errors/RacingApplicationError';
export interface LeagueScheduleDTO {
seasonStartDate: string;
raceStartTime: string;
timezoneId: string;
recurrenceStrategy: 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';
intervalWeeks?: number | undefined;
weekdays?: Weekday[] | undefined;
monthlyOrdinal?: 1 | 2 | 3 | 4 | undefined;
monthlyWeekday?: Weekday | undefined;
plannedRounds: number;
}
export interface LeagueSchedulePreviewDTO {
rounds: Array<{ roundNumber: number; scheduledAt: string; timezoneId: string }>;
summary: string;
}
export function leagueTimingsToScheduleDTO(
timings: LeagueTimingsFormDTO,
): LeagueScheduleDTO | null {
if (
!timings.seasonStartDate ||
!timings.raceStartTime ||
!timings.timezoneId ||
!timings.recurrenceStrategy ||
!timings.roundsPlanned
) {
return null;
}
return {
seasonStartDate: timings.seasonStartDate,
raceStartTime: timings.raceStartTime,
timezoneId: timings.timezoneId,
recurrenceStrategy: timings.recurrenceStrategy,
intervalWeeks: timings.intervalWeeks,
weekdays: timings.weekdays,
monthlyOrdinal: timings.monthlyOrdinal,
monthlyWeekday: timings.monthlyWeekday,
plannedRounds: timings.roundsPlanned,
};
}
export function scheduleDTOToSeasonSchedule(dto: LeagueScheduleDTO): SeasonSchedule {
if (!dto.seasonStartDate) {
throw new BusinessRuleViolationError('seasonStartDate is required');
}
if (!dto.raceStartTime) {
throw new BusinessRuleViolationError('raceStartTime is required');
}
if (!dto.timezoneId) {
throw new BusinessRuleViolationError('timezoneId is required');
}
if (!dto.recurrenceStrategy) {
throw new BusinessRuleViolationError('recurrenceStrategy is required');
}
if (!Number.isInteger(dto.plannedRounds) || dto.plannedRounds <= 0) {
throw new BusinessRuleViolationError('plannedRounds must be a positive integer');
}
const startDate = new Date(dto.seasonStartDate);
if (Number.isNaN(startDate.getTime())) {
throw new BusinessRuleViolationError(
`seasonStartDate must be a valid date, got "${dto.seasonStartDate}"`,
);
}
const timeOfDay = RaceTimeOfDay.fromString(dto.raceStartTime);
const timezone = new LeagueTimezone(dto.timezoneId);
let recurrence: RecurrenceStrategy;
if (dto.recurrenceStrategy === 'weekly') {
if (!dto.weekdays || dto.weekdays.length === 0) {
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 BusinessRuleViolationError('weekdays are required for everyNWeeks recurrence');
}
if (dto.intervalWeeks == null) {
throw new BusinessRuleViolationError(
'intervalWeeks is required for everyNWeeks recurrence',
);
}
recurrence = RecurrenceStrategyFactory.everyNWeeks(
dto.intervalWeeks,
new WeekdaySet(dto.weekdays),
);
} else if (dto.recurrenceStrategy === 'monthlyNthWeekday') {
if (!dto.monthlyOrdinal || !dto.monthlyWeekday) {
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 BusinessRuleViolationError(`Unknown recurrenceStrategy "${dto.recurrenceStrategy}"`);
}
return new SeasonSchedule({
startDate,
timeOfDay,
timezone,
recurrence,
plannedRounds: dto.plannedRounds,
});
}

View File

@@ -0,0 +1,20 @@
export interface LeagueScoringChampionshipDTO {
id: string;
name: string;
type: 'driver' | 'team' | 'nations' | 'trophy';
sessionTypes: string[];
pointsPreview: Array<{ sessionType: string; position: number; points: number }>;
bonusSummary: string[];
dropPolicyDescription: string;
}
export interface LeagueScoringConfigDTO {
leagueId: string;
seasonId: string;
gameId: string;
gameName: string;
scoringPresetId?: string;
scoringPresetName?: string;
dropPolicySummary: string;
championships: LeagueScoringChampionshipDTO[];
}

View File

@@ -0,0 +1,41 @@
export interface LeagueSummaryScoringDTO {
gameId: string;
gameName: string;
primaryChampionshipType: 'driver' | 'team' | 'nations' | 'trophy';
scoringPresetId: string;
scoringPresetName: string;
dropPolicySummary: string;
/**
* Human-readable scoring pattern summary combining preset name and drop policy,
* e.g. "Sprint + Main • Best 6 results of 8 count towards the championship."
*/
scoringPatternSummary: string;
}
export interface LeagueSummaryDTO {
id: string;
name: string;
description?: string;
createdAt: Date;
ownerId: string;
maxDrivers?: number;
usedDriverSlots?: number;
maxTeams?: number;
usedTeamSlots?: number;
/**
* Human-readable structure summary derived from capacity and (future) team settings,
* e.g. "Solo • 24 drivers" or "Teams • 12 × 2 drivers".
*/
structureSummary?: string;
/**
* Human-readable scoring pattern summary for list views,
* e.g. "Sprint + Main • Best 6 results of 8 count towards the championship."
*/
scoringPatternSummary?: string;
/**
* Human-readable timing summary for list views,
* e.g. "30 min Quali • 40 min Race".
*/
timingSummary?: string;
scoring?: LeagueSummaryScoringDTO;
}

View File

@@ -0,0 +1,14 @@
export type RaceDTO = {
id: string;
leagueId: string;
scheduledAt: string;
track: string;
trackId?: string;
car: string;
carId?: string;
sessionType: 'practice' | 'qualifying' | 'race';
status: 'scheduled' | 'running' | 'completed' | 'cancelled';
strengthOfField?: number;
registeredCount?: number;
maxParticipants?: number;
};

View File

@@ -0,0 +1,8 @@
export interface IsDriverRegisteredForRaceQueryParamsDTO {
raceId: string;
driverId: string;
}
export interface GetRaceRegistrationsQueryParamsDTO {
raceId: string;
}

View File

@@ -0,0 +1,5 @@
export interface RegisterForRaceCommandDTO {
raceId: string;
leagueId: string;
driverId: string;
}

View File

@@ -0,0 +1,9 @@
export type ResultDTO = {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
};

View File

@@ -0,0 +1,8 @@
export type StandingDTO = {
leagueId: string;
driverId: string;
points: number;
wins: number;
position: number;
racesCompleted: number;
};

View File

@@ -0,0 +1,58 @@
import type { Team } from '../../domain/entities/Team';
import type {
TeamJoinRequest,
TeamMembership,
} from '../../domain/types/TeamMembership';
export interface JoinTeamCommandDTO {
teamId: string;
driverId: string;
}
export interface LeaveTeamCommandDTO {
teamId: string;
driverId: string;
}
export interface ApproveTeamJoinRequestCommandDTO {
requestId: string;
}
export interface RejectTeamJoinRequestCommandDTO {
requestId: string;
}
export interface UpdateTeamCommandDTO {
teamId: string;
updates: Partial<Pick<Team, 'name' | 'tag' | 'description' | 'leagues'>>;
updatedBy: string;
}
export type GetAllTeamsQueryResultDTO = Team[];
export interface GetTeamDetailsQueryParamsDTO {
teamId: string;
driverId: string;
}
export interface GetTeamDetailsQueryResultDTO {
team: Team;
membership: TeamMembership | null;
}
export interface GetTeamMembersQueryParamsDTO {
teamId: string;
}
export interface GetTeamJoinRequestsQueryParamsDTO {
teamId: string;
}
export interface GetDriverTeamQueryParamsDTO {
driverId: string;
}
export interface GetDriverTeamQueryResultDTO {
team: Team;
membership: TeamMembership;
}

View File

@@ -0,0 +1,4 @@
export interface WithdrawFromRaceCommandDTO {
raceId: string;
driverId: string;
}

View File

@@ -0,0 +1,80 @@
import type { IApplicationError, CommonApplicationErrorKind } from '@gridpilot/shared/errors';
export abstract class RacingApplicationError
extends Error
implements IApplicationError<CommonApplicationErrorKind | string, unknown>
{
readonly type = 'application' as const;
readonly context = 'racing-application';
abstract readonly kind: CommonApplicationErrorKind | string;
constructor(message: string) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}
export type RacingEntityType =
| 'race'
| 'league'
| 'team'
| 'season'
| 'sponsorship'
| 'sponsorshipRequest'
| 'driver'
| 'membership'
| 'sponsor'
| 'protest';
export interface EntityNotFoundDetails {
entity: RacingEntityType;
id: string;
}
export class EntityNotFoundError
extends RacingApplicationError
implements IApplicationError<'not_found', EntityNotFoundDetails>
{
readonly kind = 'not_found' as const;
readonly details: EntityNotFoundDetails;
constructor(details: EntityNotFoundDetails) {
super(`${details.entity} not found for id: ${details.id}`);
this.details = details;
}
}
export type PermissionDeniedReason =
| 'NOT_LEAGUE_ADMIN'
| 'NOT_LEAGUE_OWNER'
| 'NOT_TEAM_OWNER'
| 'NOT_ACTIVE_MEMBER'
| 'NOT_MEMBER'
| 'TEAM_OWNER_CANNOT_LEAVE'
| 'UNAUTHORIZED';
export class PermissionDeniedError
extends RacingApplicationError
implements IApplicationError<'forbidden', PermissionDeniedReason>
{
readonly kind = 'forbidden' as const;
constructor(public readonly reason: PermissionDeniedReason, message?: string) {
super(message ?? `Permission denied: ${reason}`);
}
get details(): PermissionDeniedReason {
return this.reason;
}
}
export class BusinessRuleViolationError
extends RacingApplicationError
implements IApplicationError<'conflict', undefined>
{
readonly kind = 'conflict' as const;
constructor(message: string) {
super(message);
}
}

View File

@@ -0,0 +1,89 @@
export * from './use-cases/JoinLeagueUseCase';
export * from './use-cases/RegisterForRaceUseCase';
export * from './use-cases/WithdrawFromRaceUseCase';
export * from './use-cases/IsDriverRegisteredForRaceUseCase';
export * from './use-cases/GetRaceRegistrationsUseCase';
export * from './use-cases/CreateTeamUseCase';
export * from './use-cases/JoinTeamUseCase';
export * from './use-cases/LeaveTeamUseCase';
export * from './use-cases/ApproveTeamJoinRequestUseCase';
export * from './use-cases/RejectTeamJoinRequestUseCase';
export * from './use-cases/UpdateTeamUseCase';
export * from './use-cases/GetAllTeamsUseCase';
export * from './use-cases/GetTeamDetailsUseCase';
export * from './use-cases/GetTeamMembersUseCase';
export * from './use-cases/GetTeamJoinRequestsUseCase';
export * from './use-cases/GetDriverTeamUseCase';
export * from './use-cases/GetLeagueStandingsUseCase';
export * from './use-cases/GetLeagueDriverSeasonStatsUseCase';
export * from './use-cases/GetAllLeaguesWithCapacityUseCase';
export * from './use-cases/GetAllLeaguesWithCapacityAndScoringUseCase';
export * from './use-cases/ListLeagueScoringPresetsUseCase';
export * from './use-cases/GetLeagueScoringConfigUseCase';
export * from './use-cases/RecalculateChampionshipStandingsUseCase';
export * from './use-cases/CreateLeagueWithSeasonAndScoringUseCase';
export * from './use-cases/GetLeagueFullConfigUseCase';
export * from './use-cases/PreviewLeagueScheduleUseCase';
export * from './use-cases/GetRaceWithSOFUseCase';
export * from './use-cases/GetLeagueStatsUseCase';
export * from './use-cases/FileProtestUseCase';
export * from './use-cases/ReviewProtestUseCase';
export * from './use-cases/ApplyPenaltyUseCase';
export * from './use-cases/QuickPenaltyUseCase';
export * from './use-cases/GetRaceProtestsUseCase';
export * from './use-cases/GetRacePenaltiesUseCase';
export * from './use-cases/RequestProtestDefenseUseCase';
export * from './use-cases/SubmitProtestDefenseUseCase';
export * from './use-cases/GetSponsorDashboardUseCase';
export * from './use-cases/GetSponsorSponsorshipsUseCase';
export * from './use-cases/ApplyForSponsorshipUseCase';
export * from './use-cases/AcceptSponsorshipRequestUseCase';
export * from './use-cases/RejectSponsorshipRequestUseCase';
export * from './use-cases/GetPendingSponsorshipRequestsUseCase';
export * from './use-cases/GetEntitySponsorshipPricingUseCase';
// Export ports
export * from './ports/DriverRatingProvider';
// Re-export domain types for legacy callers (type-only)
export type {
LeagueMembership,
MembershipRole,
MembershipStatus,
JoinRequest,
} from '../domain/entities/LeagueMembership';
export type { RaceRegistration } from '../domain/entities/RaceRegistration';
export type { Team } from '../domain/entities/Team';
export type {
TeamMembership,
TeamJoinRequest,
TeamRole,
TeamMembershipStatus,
} from '../domain/types/TeamMembership';
export type { DriverDTO } from './dto/DriverDTO';
export type { LeagueDTO } from './dto/LeagueDTO';
export type { RaceDTO } from './dto/RaceDTO';
export type { ResultDTO } from './dto/ResultDTO';
export type { StandingDTO } from './dto/StandingDTO';
export type { LeagueDriverSeasonStatsDTO } from './dto/LeagueDriverSeasonStatsDTO';
export type {
LeagueScheduleDTO,
LeagueSchedulePreviewDTO,
} from './dto/LeagueScheduleDTO';
export type {
ChampionshipStandingsDTO,
ChampionshipStandingsRowDTO,
} from './dto/ChampionshipStandingsDTO';
export type {
LeagueConfigFormModel,
LeagueStructureFormDTO,
LeagueChampionshipsFormDTO,
LeagueScoringFormDTO,
LeagueDropPolicyFormDTO,
LeagueStructureMode,
LeagueTimingsFormDTO,
LeagueStewardingFormDTO,
} from './dto/LeagueConfigFormDTO';

View File

@@ -0,0 +1,184 @@
/**
* Application Layer: Entity to DTO Mappers
*
* Transforms domain entities to plain objects for crossing architectural boundaries.
* These mappers handle the Server Component -> Client Component boundary in Next.js 15.
*/
import { Driver } from '../../domain/entities/Driver';
import { League } from '../../domain/entities/League';
import { Race } from '../../domain/entities/Race';
import { Result } from '../../domain/entities/Result';
import { Standing } from '../../domain/entities/Standing';
import type { DriverDTO } from '../dto/DriverDTO';
import type { LeagueDTO } from '../dto/LeagueDTO';
import type { RaceDTO } from '../dto/RaceDTO';
import type { ResultDTO } from '../dto/ResultDTO';
import type { StandingDTO } from '../dto/StandingDTO';
export class EntityMappers {
static toDriverDTO(driver: Driver | null): DriverDTO | null {
if (!driver) return null;
return {
id: driver.id,
iracingId: driver.iracingId,
name: driver.name,
country: driver.country,
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,
description: league.description,
ownerId: league.ownerId,
settings: league.settings,
createdAt: league.createdAt.toISOString(),
...(socialLinks !== undefined ? { socialLinks } : {}),
};
}
static toLeagueDTOs(leagues: League[]): LeagueDTO[] {
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 {
if (!race) return null;
return {
id: race.id,
leagueId: race.leagueId,
scheduledAt: race.scheduledAt.toISOString(),
track: race.track,
trackId: race.trackId ?? '',
car: race.car,
carId: race.carId ?? '',
sessionType: race.sessionType,
status: race.status,
...(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) => ({
id: race.id,
leagueId: race.leagueId,
scheduledAt: race.scheduledAt.toISOString(),
track: race.track,
trackId: race.trackId ?? '',
car: race.car,
carId: race.carId ?? '',
sessionType: race.sessionType,
status: race.status,
...(race.strengthOfField !== undefined
? { strengthOfField: race.strengthOfField }
: {}),
...(race.registeredCount !== undefined
? { registeredCount: race.registeredCount }
: {}),
...(race.maxParticipants !== undefined
? { maxParticipants: race.maxParticipants }
: {}),
}));
}
static toResultDTO(result: Result | null): ResultDTO | null {
if (!result) return null;
return {
id: result.id,
raceId: result.raceId,
driverId: result.driverId,
position: result.position,
fastestLap: result.fastestLap,
incidents: result.incidents,
startPosition: result.startPosition,
};
}
static toResultDTOs(results: Result[]): ResultDTO[] {
return results.map(result => ({
id: result.id,
raceId: result.raceId,
driverId: result.driverId,
position: result.position,
fastestLap: result.fastestLap,
incidents: result.incidents,
startPosition: result.startPosition,
}));
}
static toStandingDTO(standing: Standing | null): StandingDTO | null {
if (!standing) return null;
return {
leagueId: standing.leagueId,
driverId: standing.driverId,
points: standing.points,
wins: standing.wins,
position: standing.position,
racesCompleted: standing.racesCompleted,
};
}
static toStandingDTOs(standings: Standing[]): StandingDTO[] {
return standings.map(standing => ({
leagueId: standing.leagueId,
driverId: standing.driverId,
points: standing.points,
wins: standing.wins,
position: standing.position,
racesCompleted: standing.racesCompleted,
}));
}
}

View File

@@ -0,0 +1,2 @@
// Mappers for converting between domain entities and DTOs
// Example: driverToDTO, leagueToDTO, etc.

View File

@@ -0,0 +1,20 @@
/**
* Application Port: DriverRatingProvider
*
* Port for looking up driver ratings.
* Implemented by infrastructure adapters that connect to rating systems.
*/
export interface DriverRatingProvider {
/**
* Get the rating for a single driver
* Returns null if driver has no rating
*/
getRating(driverId: string): number | null;
/**
* Get ratings for multiple drivers
* Returns a map of driverId -> rating
*/
getRatings(driverIds: string[]): Map<string, number>;
}

View File

@@ -0,0 +1,12 @@
/**
* Application Port: IImageServicePort
*
* Abstraction used by racing application use cases to obtain image URLs
* for drivers, teams and leagues without depending on UI/media layers.
*/
export interface IImageServicePort {
getDriverAvatar(driverId: string): string;
getTeamLogo(teamId: string): string;
getLeagueCover(leagueId: string): string;
getLeagueLogo(leagueId: string): string;
}

View File

@@ -0,0 +1,46 @@
/**
* Application Port: ILiveryCompositor
*
* Defines interface for livery image composition.
* Infrastructure will provide image processing implementation.
*/
import type { LiveryDecal } from '../../domain/value-objects/LiveryDecal';
export interface CompositionResult {
success: boolean;
composedImageUrl?: string;
error?: string;
timestamp: Date;
}
export interface ILiveryCompositor {
/**
* Composite a livery by layering decals on base image
*/
composeLivery(
baseImageUrl: string,
decals: LiveryDecal[]
): Promise<CompositionResult>;
/**
* Generate a livery pack (.zip) for all drivers in a season
*/
generateLiveryPack(
seasonId: string,
liveryData: Array<{
driverId: string;
driverName: string;
carId: string;
composedImageUrl: string;
}>
): Promise<Buffer>;
/**
* Validate livery image (check for logos/text)
*/
validateLiveryImage(imageUrl: string): Promise<{
isValid: boolean;
violations?: string[];
}>;
}

View File

@@ -0,0 +1,39 @@
/**
* Application Port: ILiveryStorage
*
* Defines interface for livery image storage.
* Infrastructure will provide cloud storage adapter.
*/
export interface UploadResult {
success: boolean;
imageUrl?: string;
error?: string;
timestamp: Date;
}
export interface ILiveryStorage {
/**
* Upload a livery image
*/
upload(
imageData: Buffer | string,
fileName: string,
metadata?: Record<string, unknown>
): Promise<UploadResult>;
/**
* Download a livery image
*/
download(imageUrl: string): Promise<Buffer>;
/**
* Delete a livery image
*/
delete(imageUrl: string): Promise<void>;
/**
* Generate a signed URL for temporary access
*/
generateSignedUrl(imageUrl: string, expiresInSeconds: number): Promise<string>;
}

View File

@@ -0,0 +1,48 @@
/**
* Application Port: IPaymentGateway
*
* Defines interface for payment processing.
* Infrastructure will provide mock or real implementation.
*/
import type { Money } from '../../domain/value-objects/Money';
export interface PaymentResult {
success: boolean;
transactionId?: string;
error?: string;
timestamp: Date;
}
export interface RefundResult {
success: boolean;
refundId?: string;
error?: string;
timestamp: Date;
}
export interface IPaymentGateway {
/**
* Process a payment
*/
processPayment(
amount: Money,
payerId: string,
description: string,
metadata?: Record<string, unknown>
): Promise<PaymentResult>;
/**
* Refund a payment
*/
refund(
originalTransactionId: string,
amount: Money,
reason: string
): Promise<RefundResult>;
/**
* Verify payment status
*/
verifyPayment(transactionId: string): Promise<PaymentResult>;
}

View File

@@ -0,0 +1,26 @@
export type LeagueScoringPresetPrimaryChampionshipType =
| 'driver'
| 'team'
| 'nations'
| 'trophy';
export interface LeagueScoringPresetDTO {
id: string;
name: string;
description: string;
primaryChampionshipType: LeagueScoringPresetPrimaryChampionshipType;
sessionSummary: string;
bonusSummary: string;
dropPolicySummary: string;
}
/**
* Provider abstraction for league scoring presets used by application-layer queries.
*
* In-memory implementation is backed by the preset registry in
* InMemoryScoringRepositories.
*/
export interface LeagueScoringPresetProvider {
listPresets(): LeagueScoringPresetDTO[];
getPresetById(id: string): LeagueScoringPresetDTO | undefined;
}

View File

@@ -0,0 +1,47 @@
import type { League } from '../../domain/entities/League';
import type { Season } from '../../domain/entities/Season';
import type { LeagueScoringConfig } from '../../domain/entities/LeagueScoringConfig';
import type { Game } from '../../domain/entities/Game';
import type { LeagueScoringPresetDTO } from '../ports/LeagueScoringPresetProvider';
import type { Presenter } from '@gridpilot/shared/presentation';
export interface LeagueSummaryViewModel {
id: string;
name: string;
description: string;
ownerId: string;
createdAt: string;
maxDrivers: number;
usedDriverSlots: number;
maxTeams?: number;
usedTeamSlots?: number;
structureSummary: string;
scoringPatternSummary?: string;
timingSummary: string;
scoring?: {
gameId: string;
gameName: string;
primaryChampionshipType: 'driver' | 'team' | 'nations' | 'trophy';
scoringPresetId: string;
scoringPresetName: string;
dropPolicySummary: string;
scoringPatternSummary: string;
};
}
export interface AllLeaguesWithCapacityAndScoringViewModel {
leagues: LeagueSummaryViewModel[];
totalCount: number;
}
export interface LeagueEnrichedData {
league: League;
usedDriverSlots: number;
season?: Season;
scoringConfig?: LeagueScoringConfig;
game?: Game;
preset?: LeagueScoringPresetDTO;
}
export interface IAllLeaguesWithCapacityAndScoringPresenter
extends Presenter<LeagueEnrichedData[], AllLeaguesWithCapacityAndScoringViewModel> {}

View File

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

View File

@@ -0,0 +1,29 @@
import type { Presenter } from '@gridpilot/shared/presentation';
export type AllRacesStatus = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'all';
export interface AllRacesListItemViewModel {
id: string;
track: string;
car: string;
scheduledAt: string;
status: 'scheduled' | 'running' | 'completed' | 'cancelled';
leagueId: string;
leagueName: string;
strengthOfField: number | null;
}
export interface AllRacesFilterOptionsViewModel {
statuses: { value: AllRacesStatus; label: string }[];
leagues: { id: string; name: string }[];
}
export interface AllRacesPageViewModel {
races: AllRacesListItemViewModel[];
filters: AllRacesFilterOptionsViewModel;
}
export type AllRacesPageResultDTO = AllRacesPageViewModel;
export interface IAllRacesPagePresenter
extends Presenter<AllRacesPageResultDTO, AllRacesPageViewModel> {}

View File

@@ -0,0 +1,34 @@
import type { Presenter } from '@gridpilot/shared/presentation';
export interface TeamListItemViewModel {
id: string;
name: string;
tag: string;
description: string;
memberCount: number;
leagues: string[];
specialization?: 'endurance' | 'sprint' | 'mixed';
region?: string;
languages?: string[];
}
export interface AllTeamsViewModel {
teams: TeamListItemViewModel[];
totalCount: number;
}
export interface AllTeamsResultDTO {
teams: Array<{
id: string;
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
createdAt: Date;
memberCount: number;
}>;
}
export interface IAllTeamsPresenter
extends Presenter<AllTeamsResultDTO, AllTeamsViewModel> {}

View File

@@ -0,0 +1,90 @@
import type { Presenter } from '@gridpilot/shared/presentation';
export interface DashboardDriverSummaryViewModel {
id: string;
name: string;
country: string;
avatarUrl: string;
rating: number | null;
globalRank: number | null;
totalRaces: number;
wins: number;
podiums: number;
consistency: number | null;
}
export interface DashboardRaceSummaryViewModel {
id: string;
leagueId: string;
leagueName: string;
track: string;
car: string;
scheduledAt: string;
status: 'scheduled' | 'running' | 'completed' | 'cancelled';
isMyLeague: boolean;
}
export interface DashboardRecentResultViewModel {
raceId: string;
raceName: string;
leagueId: string;
leagueName: string;
finishedAt: string;
position: number;
incidents: number;
}
export interface DashboardLeagueStandingSummaryViewModel {
leagueId: string;
leagueName: string;
position: number;
totalDrivers: number;
points: number;
}
export interface DashboardFeedItemSummaryViewModel {
id: string;
type: string;
headline: string;
body?: string;
timestamp: string;
ctaLabel?: string;
ctaHref?: string;
}
export interface DashboardFeedSummaryViewModel {
notificationCount: number;
items: DashboardFeedItemSummaryViewModel[];
}
export interface DashboardFriendSummaryViewModel {
id: string;
name: string;
country: string;
avatarUrl: string;
}
export interface DashboardOverviewViewModel {
currentDriver: DashboardDriverSummaryViewModel | null;
myUpcomingRaces: DashboardRaceSummaryViewModel[];
otherUpcomingRaces: DashboardRaceSummaryViewModel[];
/**
* All upcoming races for the driver, already sorted by scheduledAt ascending.
*/
upcomingRaces: DashboardRaceSummaryViewModel[];
/**
* Count of distinct leagues that are currently "active" for the driver,
* based on upcoming races and league standings.
*/
activeLeaguesCount: number;
nextRace: DashboardRaceSummaryViewModel | null;
recentResults: DashboardRecentResultViewModel[];
leagueStandingsSummaries: DashboardLeagueStandingSummaryViewModel[];
feedSummary: DashboardFeedSummaryViewModel;
friends: DashboardFriendSummaryViewModel[];
}
export type DashboardOverviewResultDTO = DashboardOverviewViewModel;
export interface IDashboardOverviewPresenter
extends Presenter<DashboardOverviewResultDTO, DashboardOverviewViewModel> {}

View File

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

View File

@@ -0,0 +1,33 @@
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: {
id: string;
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
specialization?: 'endurance' | 'sprint' | 'mixed';
region?: string;
languages?: string[];
};
membership: {
role: 'owner' | 'manager' | 'member';
joinedAt: string;
isActive: boolean;
};
isOwner: boolean;
canManage: boolean;
}
export interface DriverTeamResultDTO {
team: Team;
membership: TeamMembership;
driverId: string;
}
export interface IDriverTeamPresenter
extends Presenter<DriverTeamResultDTO, DriverTeamViewModel> {}

View File

@@ -0,0 +1,45 @@
import type { Driver } from '../../domain/entities/Driver';
import type { SkillLevel } from '../../domain/services/SkillLevelService';
import type { Presenter } from '@gridpilot/shared/presentation';
export type { SkillLevel };
export interface DriverLeaderboardItemViewModel {
id: string;
name: string;
rating: number;
skillLevel: SkillLevel;
nationality: string;
racesCompleted: number;
wins: number;
podiums: number;
isActive: boolean;
rank: number;
avatarUrl: string;
}
export interface DriversLeaderboardViewModel {
drivers: DriverLeaderboardItemViewModel[];
totalRaces: number;
totalWins: number;
activeCount: number;
}
export interface 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>;
}
export interface IDriversLeaderboardPresenter
extends Presenter<DriversLeaderboardResultDTO, DriversLeaderboardViewModel> {}

View File

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

View File

@@ -0,0 +1,9 @@
export interface ImportRaceResultsSummaryViewModel {
importedCount: number;
standingsRecalculated: boolean;
}
export interface IImportRaceResultsPresenter {
present(viewModel: ImportRaceResultsSummaryViewModel): ImportRaceResultsSummaryViewModel;
getViewModel(): ImportRaceResultsSummaryViewModel | null;
}

View File

@@ -0,0 +1,43 @@
import type { Presenter } from '@gridpilot/shared/presentation';
export interface LeagueDriverSeasonStatsItemViewModel {
leagueId: string;
driverId: string;
position: number;
driverName: string;
teamId?: string;
teamName?: string;
totalPoints: number;
basePoints: number;
penaltyPoints: number;
bonusPoints: number;
pointsPerRace: number;
racesStarted: number;
racesFinished: number;
dnfs: number;
noShows: number;
avgFinish: number | null;
rating: number | null;
ratingChange: number | null;
}
export interface LeagueDriverSeasonStatsViewModel {
leagueId: string;
stats: LeagueDriverSeasonStatsItemViewModel[];
}
export interface 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

@@ -0,0 +1,65 @@
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;
basics: {
name: string;
description: string;
visibility: string;
gameId: string;
};
structure: {
mode: string;
maxDrivers: number;
maxTeams?: number;
driversPerTeam?: number;
multiClassEnabled: boolean;
};
championships: {
enableDriverChampionship: boolean;
enableTeamChampionship: boolean;
enableNationsChampionship: boolean;
enableTrophyChampionship: boolean;
};
scoring: {
patternId?: string;
customScoringEnabled: boolean;
};
dropPolicy: {
strategy: string;
n?: number;
};
timings: {
practiceMinutes: number;
qualifyingMinutes: number;
sprintRaceMinutes?: number;
mainRaceMinutes: number;
sessionCount: number;
roundsPlanned: number;
};
stewarding: {
decisionMode: string;
requireDefense: boolean;
defenseTimeLimit: number;
voteTimeLimit: number;
protestDeadlineHours: number;
stewardingClosesHours: number;
notifyAccusedOnProtest: boolean;
notifyOnVoteRequired: boolean;
requiredVotes?: number;
};
}
export interface LeagueFullConfigData {
league: League;
activeSeason?: Season;
scoringConfig?: LeagueScoringConfig;
game?: Game;
}
export interface ILeagueFullConfigPresenter
extends Presenter<LeagueFullConfigData, LeagueConfigFormViewModel> {}

View File

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

View File

@@ -0,0 +1,37 @@
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;
name: string;
type: string;
sessionTypes: string[];
pointsPreview: Array<{ sessionType: string; position: number; points: number }>;
bonusSummary: string[];
dropPolicyDescription: string;
}
export interface LeagueScoringConfigViewModel {
leagueId: string;
seasonId: string;
gameId: string;
gameName: string;
scoringPresetId?: string;
scoringPresetName?: string;
dropPolicySummary: string;
championships: LeagueScoringChampionshipViewModel[];
}
export interface LeagueScoringConfigData {
leagueId: string;
seasonId: string;
gameId: string;
gameName: string;
scoringPresetId?: string;
preset?: LeagueScoringPresetDTO;
championships: ChampionshipConfig[];
}
export interface ILeagueScoringConfigPresenter
extends Presenter<LeagueScoringConfigData, LeagueScoringConfigViewModel> {}

View File

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

View File

@@ -0,0 +1,26 @@
import type { Standing } from '../../domain/entities/Standing';
import type { Presenter } from '@gridpilot/shared/presentation/Presenter';
export interface StandingItemViewModel {
id: string;
leagueId: string;
seasonId: string;
driverId: string;
position: number;
points: number;
wins: number;
podiums: number;
racesCompleted: number;
}
export interface LeagueStandingsViewModel {
leagueId: string;
standings: StandingItemViewModel[];
}
export interface LeagueStandingsResultDTO {
standings: Standing[];
}
export interface ILeagueStandingsPresenter
extends Presenter<LeagueStandingsResultDTO, LeagueStandingsViewModel> {}

View File

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

View File

@@ -0,0 +1,7 @@
import type { Presenter } from '@gridpilot/shared/presentation';
import type { GetPendingSponsorshipRequestsResultDTO } from '../use-cases/GetPendingSponsorshipRequestsUseCase';
export type PendingSponsorshipRequestsViewModel = GetPendingSponsorshipRequestsResultDTO;
export interface IPendingSponsorshipRequestsPresenter
extends Presenter<GetPendingSponsorshipRequestsResultDTO, PendingSponsorshipRequestsViewModel> {}

View File

@@ -0,0 +1,105 @@
export interface ProfileOverviewDriverSummaryViewModel {
id: string;
name: string;
country: string;
avatarUrl: string;
iracingId: string | null;
joinedAt: string;
rating: number | null;
globalRank: number | null;
consistency: number | null;
bio: string | null;
totalDrivers: number | null;
}
export interface ProfileOverviewStatsViewModel {
totalRaces: number;
wins: number;
podiums: number;
dnfs: number;
avgFinish: number | null;
bestFinish: number | null;
worstFinish: number | null;
finishRate: number | null;
winRate: number | null;
podiumRate: number | null;
percentile: number | null;
rating: number | null;
consistency: number | null;
overallRank: number | null;
}
export interface ProfileOverviewFinishDistributionViewModel {
totalRaces: number;
wins: number;
podiums: number;
topTen: number;
dnfs: number;
other: number;
}
export interface ProfileOverviewTeamMembershipViewModel {
teamId: string;
teamName: string;
teamTag: string | null;
role: string;
joinedAt: string;
isCurrent: boolean;
}
export interface ProfileOverviewSocialFriendSummaryViewModel {
id: string;
name: string;
country: string;
avatarUrl: string;
}
export interface ProfileOverviewSocialSummaryViewModel {
friendsCount: number;
friends: ProfileOverviewSocialFriendSummaryViewModel[];
}
export type ProfileOverviewSocialPlatform = 'twitter' | 'youtube' | 'twitch' | 'discord';
export type ProfileOverviewAchievementRarity = 'common' | 'rare' | 'epic' | 'legendary';
export interface ProfileOverviewAchievementViewModel {
id: string;
title: string;
description: string;
icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap';
rarity: ProfileOverviewAchievementRarity;
earnedAt: string;
}
export interface ProfileOverviewSocialHandleViewModel {
platform: ProfileOverviewSocialPlatform;
handle: string;
url: string;
}
export interface ProfileOverviewExtendedProfileViewModel {
socialHandles: ProfileOverviewSocialHandleViewModel[];
achievements: ProfileOverviewAchievementViewModel[];
racingStyle: string;
favoriteTrack: string;
favoriteCar: string;
timezone: string;
availableHours: string;
lookingForTeam: boolean;
openToRequests: boolean;
}
export interface ProfileOverviewViewModel {
currentDriver: ProfileOverviewDriverSummaryViewModel | null;
stats: ProfileOverviewStatsViewModel | null;
finishDistribution: ProfileOverviewFinishDistributionViewModel | null;
teamMemberships: ProfileOverviewTeamMembershipViewModel[];
socialSummary: ProfileOverviewSocialSummaryViewModel;
extendedProfile: ProfileOverviewExtendedProfileViewModel | null;
}
export interface IProfileOverviewPresenter {
present(viewModel: ProfileOverviewViewModel): void;
getViewModel(): ProfileOverviewViewModel | null;
}

View File

@@ -0,0 +1,60 @@
import type { SessionType, RaceStatus } from '../../domain/entities/Race';
import type { Presenter } from '@gridpilot/shared/presentation';
export interface RaceDetailEntryViewModel {
id: string;
name: string;
country: string;
avatarUrl: string;
rating: number | null;
isCurrentUser: boolean;
}
export interface RaceDetailUserResultViewModel {
position: number;
startPosition: number;
incidents: number;
fastestLap: number;
positionChange: number;
isPodium: boolean;
isClean: boolean;
ratingChange: number | null;
}
export interface RaceDetailRaceViewModel {
id: string;
leagueId: string;
track: string;
car: string;
scheduledAt: string;
sessionType: SessionType;
status: RaceStatus;
strengthOfField: number | null;
registeredCount?: number;
maxParticipants?: number;
}
export interface RaceDetailLeagueViewModel {
id: string;
name: string;
description: string;
settings: {
maxDrivers?: number;
qualifyingFormat?: string;
};
}
export interface RaceDetailViewModel {
race: RaceDetailRaceViewModel | null;
league: RaceDetailLeagueViewModel | null;
entryList: RaceDetailEntryViewModel[];
registration: {
isUserRegistered: boolean;
canRegister: boolean;
};
userResult: RaceDetailUserResultViewModel | null;
error?: string;
}
export interface IRaceDetailPresenter
extends Presenter<RaceDetailViewModel, RaceDetailViewModel> {}

View File

@@ -0,0 +1,32 @@
import type { Penalty, PenaltyType, PenaltyStatus } from '../../domain/entities/Penalty';
import type { Presenter } from '@gridpilot/shared/presentation/Presenter';
export interface RacePenaltyViewModel {
id: string;
raceId: string;
driverId: string;
driverName: string;
type: PenaltyType;
value?: number;
reason: string;
protestId?: string;
issuedBy: string;
issuedByName: string;
status: PenaltyStatus;
description: string;
issuedAt: string;
appliedAt?: string;
notes?: string;
}
export interface RacePenaltiesViewModel {
penalties: RacePenaltyViewModel[];
}
export interface RacePenaltiesResultDTO {
penalties: Penalty[];
driverMap: Map<string, string>;
}
export interface IRacePenaltiesPresenter
extends Presenter<RacePenaltiesResultDTO, RacePenaltiesViewModel> {}

View File

@@ -0,0 +1,32 @@
import type { Protest, ProtestStatus, ProtestIncident } from '../../domain/entities/Protest';
import type { Presenter } from '@gridpilot/shared/presentation/Presenter';
export interface RaceProtestViewModel {
id: string;
raceId: string;
protestingDriverId: string;
protestingDriverName: string;
accusedDriverId: string;
accusedDriverName: string;
incident: ProtestIncident;
comment?: string;
proofVideoUrl?: string;
status: ProtestStatus;
reviewedBy?: string;
reviewedByName?: string;
decisionNotes?: string;
filedAt: string;
reviewedAt?: string;
}
export interface RaceProtestsViewModel {
protests: RaceProtestViewModel[];
}
export interface RaceProtestsResultDTO {
protests: Protest[];
driverMap: Map<string, string>;
}
export interface IRaceProtestsPresenter
extends Presenter<RaceProtestsResultDTO, RaceProtestsViewModel> {}

View File

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

View File

@@ -0,0 +1,39 @@
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;
leagueId: string;
track: string;
scheduledAt: Date;
status: RaceStatus;
}
export interface RaceResultsLeagueViewModel {
id: string;
name: string;
}
export interface RaceResultsPenaltySummaryViewModel {
driverId: string;
type: PenaltyType;
value?: number;
}
export interface RaceResultsDetailViewModel {
race: RaceResultsHeaderViewModel | null;
league: RaceResultsLeagueViewModel | null;
results: Result[];
drivers: Driver[];
penalties: RaceResultsPenaltySummaryViewModel[];
pointsSystem?: Record<number, number>;
fastestLapTime?: number;
currentDriverId?: string;
error?: string;
}
export interface IRaceResultsDetailPresenter
extends Presenter<RaceResultsDetailViewModel, RaceResultsDetailViewModel> {}

View File

@@ -0,0 +1,36 @@
import type { Presenter } from '@gridpilot/shared/presentation';
export interface RaceWithSOFViewModel {
id: string;
leagueId: string;
scheduledAt: string;
track: string;
trackId: string;
car: string;
carId: string;
sessionType: string;
status: string;
strengthOfField: number | null;
registeredCount: number;
maxParticipants: number;
participantCount: number;
}
export interface 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

@@ -0,0 +1,35 @@
import type { Presenter } from '@gridpilot/shared/presentation';
export interface RaceListItemViewModel {
id: string;
track: string;
car: string;
scheduledAt: string;
status: 'scheduled' | 'running' | 'completed' | 'cancelled';
leagueId: string;
leagueName: string;
strengthOfField: number | null;
isUpcoming: boolean;
isLive: boolean;
isPast: boolean;
}
export interface RacesPageViewModel {
races: RaceListItemViewModel[];
stats: {
total: number;
scheduled: number;
running: number;
completed: number;
};
liveRaces: RaceListItemViewModel[];
upcomingThisWeek: RaceListItemViewModel[];
recentResults: RaceListItemViewModel[];
}
export interface RacesPageResultDTO {
races: any[];
}
export interface IRacesPagePresenter
extends Presenter<RacesPageResultDTO, RacesPageViewModel> {}

View File

@@ -0,0 +1,7 @@
import type { SponsorDashboardDTO } from '../use-cases/GetSponsorDashboardUseCase';
import type { Presenter } from '@gridpilot/shared/presentation';
export type SponsorDashboardViewModel = SponsorDashboardDTO | null;
export interface ISponsorDashboardPresenter
extends Presenter<SponsorDashboardDTO | null, SponsorDashboardViewModel> {}

View File

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

View File

@@ -0,0 +1,30 @@
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: {
id: string;
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
createdAt: string;
};
membership: {
role: 'owner' | 'manager' | 'member';
joinedAt: string;
isActive: boolean;
} | null;
canManage: boolean;
}
export interface TeamDetailsResultDTO {
team: Team;
membership: TeamMembership | null;
driverId: string;
}
export interface ITeamDetailsPresenter
extends Presenter<TeamDetailsResultDTO, TeamDetailsViewModel> {}

View File

@@ -0,0 +1,27 @@
import type { TeamJoinRequest } from '../../domain/types/TeamMembership';
import type { Presenter } from '@gridpilot/shared/presentation';
export interface TeamJoinRequestViewModel {
requestId: string;
driverId: string;
driverName: string;
teamId: string;
status: 'pending' | 'approved' | 'rejected';
requestedAt: string;
avatarUrl: string;
}
export interface TeamJoinRequestsViewModel {
requests: TeamJoinRequestViewModel[];
pendingCount: number;
totalCount: number;
}
export interface TeamJoinRequestsResultDTO {
requests: TeamJoinRequest[];
driverNames: Record<string, string>;
avatarUrls: Record<string, string>;
}
export interface ITeamJoinRequestsPresenter
extends Presenter<TeamJoinRequestsResultDTO, TeamJoinRequestsViewModel> {}

View File

@@ -0,0 +1,28 @@
import type { TeamMembership } from '../../domain/types/TeamMembership';
import type { Presenter } from '@gridpilot/shared/presentation';
export interface TeamMemberViewModel {
driverId: string;
driverName: string;
role: 'owner' | 'manager' | 'member';
joinedAt: string;
isActive: boolean;
avatarUrl: string;
}
export interface TeamMembersViewModel {
members: TeamMemberViewModel[];
totalCount: number;
ownerCount: number;
managerCount: number;
memberCount: number;
}
export interface TeamMembersResultDTO {
memberships: TeamMembership[];
driverNames: Record<string, string>;
avatarUrls: Record<string, string>;
}
export interface ITeamMembersPresenter
extends Presenter<TeamMembersResultDTO, TeamMembersViewModel> {}

View File

@@ -0,0 +1,40 @@
import type { Presenter } from '@gridpilot/shared/presentation';
export type SkillLevel = 'beginner' | 'intermediate' | 'advanced' | 'pro';
export interface TeamLeaderboardItemViewModel {
id: string;
name: string;
memberCount: number;
rating: number | null;
totalWins: number;
totalRaces: number;
performanceLevel: SkillLevel;
isRecruiting: boolean;
createdAt: Date;
description?: string;
specialization?: 'endurance' | 'sprint' | 'mixed';
region?: string;
languages?: string[];
}
export interface TeamsLeaderboardViewModel {
teams: TeamLeaderboardItemViewModel[];
recruitingCount: number;
/**
* Teams grouped by their skill level for UI display.
*/
groupsBySkillLevel: Record<SkillLevel, TeamLeaderboardItemViewModel[]>;
/**
* Precomputed top teams ordered for leaderboard preview.
*/
topTeams: TeamLeaderboardItemViewModel[];
}
export interface TeamsLeaderboardResultDTO {
teams: unknown[];
recruitingCount: number;
}
export interface ITeamsLeaderboardPresenter
extends Presenter<TeamsLeaderboardResultDTO, TeamsLeaderboardViewModel> {}

View File

@@ -0,0 +1,104 @@
/**
* Use Case: AcceptSponsorshipRequestUseCase
*
* Allows an entity owner to accept a sponsorship request.
* This creates an active sponsorship and notifies the sponsor.
*/
import type { ILogger } from '../../../shared/src/logging/ILogger';
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship';
import type { AsyncUseCase } from '@gridpilot/shared/application';
export interface AcceptSponsorshipRequestDTO {
requestId: string;
respondedBy: string; // driverId of the person accepting
}
export interface AcceptSponsorshipRequestResultDTO {
requestId: string;
sponsorshipId: string;
status: 'accepted';
acceptedAt: Date;
platformFee: number;
netAmount: number;
}
export class AcceptSponsorshipRequestUseCase
implements AsyncUseCase<AcceptSponsorshipRequestDTO, AcceptSponsorshipRequestResultDTO> {
constructor(
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly logger: ILogger,
) {}
async execute(dto: AcceptSponsorshipRequestDTO): Promise<AcceptSponsorshipRequestResultDTO> {
this.logger.debug(`Attempting to accept sponsorship request: ${dto.requestId}`, { requestId: dto.requestId, respondedBy: dto.respondedBy });
try {
// Find the request
const request = await this.sponsorshipRequestRepo.findById(dto.requestId);
if (!request) {
this.logger.warn(`Sponsorship request not found: ${dto.requestId}`, { requestId: dto.requestId });
throw new Error('Sponsorship request not found');
}
if (!request.isPending()) {
this.logger.warn(`Cannot accept a ${request.status} sponsorship request: ${dto.requestId}`, { requestId: dto.requestId, status: request.status });
throw new Error(`Cannot accept a ${request.status} sponsorship request`);
}
this.logger.info(`Sponsorship request ${dto.requestId} found and is pending. Proceeding with acceptance.`, { requestId: dto.requestId });
// Accept the request
const acceptedRequest = request.accept(dto.respondedBy);
await this.sponsorshipRequestRepo.update(acceptedRequest);
this.logger.debug(`Sponsorship request ${dto.requestId} accepted and updated in repository.`, { requestId: dto.requestId });
// If this is a season sponsorship, create the SeasonSponsorship record
let sponsorshipId = `spons_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
if (request.entityType === 'season') {
this.logger.debug(`Sponsorship request ${dto.requestId} is for a season. Creating SeasonSponsorship record.`, { requestId: dto.requestId, entityType: request.entityType });
const season = await this.seasonRepository.findById(request.entityId);
if (!season) {
this.logger.warn(`Season not found for sponsorship request ${dto.requestId} and entityId ${request.entityId}`, { requestId: dto.requestId, entityId: request.entityId });
throw new Error('Season not found for sponsorship request');
}
const sponsorship = SeasonSponsorship.create({
id: sponsorshipId,
seasonId: season.id,
leagueId: season.leagueId,
sponsorId: request.sponsorId,
tier: request.tier,
pricing: request.offeredAmount,
status: 'active',
});
await this.seasonSponsorshipRepo.create(sponsorship);
this.logger.info(`Season sponsorship ${sponsorshipId} created for request ${dto.requestId}.`, { sponsorshipId, requestId: dto.requestId });
}
// TODO: In a real implementation, we would:
// 1. Create notification for the sponsor
// 2. Process payment
// 3. Update wallet balances
this.logger.info(`Sponsorship request ${acceptedRequest.id} successfully accepted.`, { requestId: acceptedRequest.id, sponsorshipId });
return {
requestId: acceptedRequest.id,
sponsorshipId,
status: 'accepted',
acceptedAt: acceptedRequest.respondedAt!,
platformFee: acceptedRequest.getPlatformFee().amount,
netAmount: acceptedRequest.getNetAmount().amount,
};
} catch (error: any) {
this.logger.error(`Failed to accept sponsorship request ${dto.requestId}: ${error.message}`, { requestId: dto.requestId, error: error.message, stack: error.stack });
throw error;
}
}
}

View File

@@ -0,0 +1,124 @@
/**
* Use Case: ApplyForSponsorshipUseCase
*
* Allows a sponsor to apply for a sponsorship slot on any entity
* (driver, team, race, or season/league).
*/
import { SponsorshipRequest, type SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship';
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository';
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import { Money, type Currency } from '../../domain/value-objects/Money';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import {
EntityNotFoundError,
BusinessRuleViolationError,
} from '../errors/RacingApplicationError';
import type { ILogger } from '../../../shared/src/logging/ILogger';
export interface ApplyForSponsorshipDTO {
sponsorId: string;
entityType: SponsorableEntityType;
entityId: string;
tier: SponsorshipTier;
offeredAmount: number; // in cents
currency?: Currency;
message?: string;
}
export interface ApplyForSponsorshipResultDTO {
requestId: string;
status: 'pending';
createdAt: Date;
}
export class ApplyForSponsorshipUseCase
implements AsyncUseCase<ApplyForSponsorshipDTO, ApplyForSponsorshipResultDTO>
{
constructor(
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository,
private readonly sponsorRepo: ISponsorRepository,
private readonly logger: ILogger,
) {}
async execute(dto: ApplyForSponsorshipDTO): Promise<ApplyForSponsorshipResultDTO> {
this.logger.debug('Attempting to apply for sponsorship', { dto });
// Validate sponsor exists
const sponsor = await this.sponsorRepo.findById(dto.sponsorId);
if (!sponsor) {
this.logger.error('Sponsor not found', { sponsorId: dto.sponsorId });
throw new EntityNotFoundError({ entity: 'sponsor', id: dto.sponsorId });
}
// Check if entity accepts sponsorship applications
const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId);
if (!pricing) {
this.logger.warn('Sponsorship pricing not set up for this entity', { entityType: dto.entityType, entityId: dto.entityId });
throw new BusinessRuleViolationError('This entity has not set up sponsorship pricing');
}
if (!pricing.acceptingApplications) {
this.logger.warn('Entity not accepting sponsorship applications', { entityType: dto.entityType, entityId: dto.entityId });
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) {
this.logger.warn(`No ${dto.tier} sponsorship slots are available for entity ${dto.entityId}`);
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,
);
if (hasPending) {
this.logger.warn('Sponsor already has a pending request for this entity', { sponsorId: dto.sponsorId, entityType: dto.entityType, entityId: dto.entityId });
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) {
this.logger.warn(`Offered amount ${dto.offeredAmount} is less than minimum ${minPrice.amount} for entity ${dto.entityId}, tier ${dto.tier}`);
throw new BusinessRuleViolationError(
`Offered amount must be at least ${minPrice.format()}`,
);
}
// Create the sponsorship request
const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const offeredAmount = Money.create(dto.offeredAmount, dto.currency ?? 'USD');
const request = SponsorshipRequest.create({
id: requestId,
sponsorId: dto.sponsorId,
entityType: dto.entityType,
entityId: dto.entityId,
tier: dto.tier,
offeredAmount,
...(dto.message !== undefined ? { message: dto.message } : {}),
});
await this.sponsorshipRequestRepo.create(request);
return {
requestId: request.id,
status: 'pending',
createdAt: request.createdAt,
};
}
}

View File

@@ -0,0 +1,104 @@
/**
* Application Use Case: ApplyPenaltyUseCase
*
* Allows a steward to apply a penalty to a driver for an incident during a race.
* The penalty can be standalone or linked to an upheld protest.
*/
import { Penalty, type PenaltyType } from '../../domain/entities/Penalty';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { randomUUID } from 'crypto';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type { ILogger } from '../../../shared/src/logging/ILogger';
export interface ApplyPenaltyCommand {
raceId: string;
driverId: string;
stewardId: string;
type: PenaltyType;
value?: number;
reason: string;
protestId?: string;
notes?: string;
}
export class ApplyPenaltyUseCase
implements AsyncUseCase<ApplyPenaltyCommand, { penaltyId: string }> {
constructor(
private readonly penaltyRepository: IPenaltyRepository,
private readonly protestRepository: IProtestRepository,
private readonly raceRepository: IRaceRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly logger: ILogger,
) {}
async execute(command: ApplyPenaltyCommand): Promise<{ penaltyId: string }> {
this.logger.debug('ApplyPenaltyUseCase: Executing with command', command);
try {
// Validate race exists
const race = await this.raceRepository.findById(command.raceId);
if (!race) {
this.logger.warn(`ApplyPenaltyUseCase: Race with ID ${command.raceId} not found.`);
throw new Error('Race not found');
}
this.logger.debug(`ApplyPenaltyUseCase: Race ${race.id} found.`);
// Validate steward has authority (owner or admin of the league)
const memberships = await this.leagueMembershipRepository.getLeagueMembers(race.leagueId);
const stewardMembership = memberships.find(
m => m.driverId === command.stewardId && m.status === 'active'
);
if (!stewardMembership || (stewardMembership.role !== 'owner' && stewardMembership.role !== 'admin')) {
this.logger.warn(`ApplyPenaltyUseCase: Steward ${command.stewardId} does not have authority for league ${race.leagueId}.`);
throw new Error('Only league owners and admins can apply penalties');
}
this.logger.debug(`ApplyPenaltyUseCase: Steward ${command.stewardId} has authority.`);
// If linked to a protest, validate the protest exists and is upheld
if (command.protestId) {
const protest = await this.protestRepository.findById(command.protestId);
if (!protest) {
this.logger.warn(`ApplyPenaltyUseCase: Protest with ID ${command.protestId} not found.`);
throw new Error('Protest not found');
}
if (protest.status !== 'upheld') {
this.logger.warn(`ApplyPenaltyUseCase: Protest ${protest.id} is not upheld. Status: ${protest.status}`);
throw new Error('Can only create penalties for upheld protests');
}
if (protest.raceId !== command.raceId) {
this.logger.warn(`ApplyPenaltyUseCase: Protest ${protest.id} is for race ${protest.raceId}, not ${command.raceId}.`);
throw new Error('Protest is not for this race');
}
this.logger.debug(`ApplyPenaltyUseCase: Protest ${protest.id} is valid and upheld.`);
}
// Create the penalty
const penalty = Penalty.create({
id: randomUUID(),
leagueId: race.leagueId,
raceId: command.raceId,
driverId: command.driverId,
type: command.type,
...(command.value !== undefined ? { value: command.value } : {}),
reason: command.reason,
...(command.protestId !== undefined ? { protestId: command.protestId } : {}),
issuedBy: command.stewardId,
status: 'pending',
issuedAt: new Date(),
...(command.notes !== undefined ? { notes: command.notes } : {}),
});
await this.penaltyRepository.create(penalty);
this.logger.info(`ApplyPenaltyUseCase: Successfully applied penalty ${penalty.id} for driver ${command.driverId} in race ${command.raceId}.`);
return { penaltyId: penalty.id };
} catch (error) {
this.logger.error('ApplyPenaltyUseCase: Failed to apply penalty', { command, error: error.message });
throw error;
}
}
}

View File

@@ -0,0 +1,61 @@
import type { ILogger } from '../../../shared/src/logging/ILogger';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type {
TeamMembership,
TeamMembershipStatus,
TeamRole,
TeamJoinRequest,
} from '../../domain/types/TeamMembership';
import type { ApproveTeamJoinRequestCommandDTO } from '../dto/TeamCommandAndQueryDTO';
import type { AsyncUseCase } from '@gridpilot/shared/application';
export class ApproveTeamJoinRequestUseCase
implements AsyncUseCase<ApproveTeamJoinRequestCommandDTO, void> {
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
private readonly logger: ILogger,
) {}
async execute(command: ApproveTeamJoinRequestCommandDTO): Promise<void> {
const { requestId } = command;
this.logger.debug(
`Attempting to approve team join request with ID: ${requestId}`,
);
// There is no repository method to look up a single request by ID,
try {
// There is no repository method to look up a single request by ID,
// so we rely on the repository implementation to surface all relevant
// requests via getJoinRequests and search by ID here.
const allRequests: TeamJoinRequest[] = await this.membershipRepository.getJoinRequests(
// For the in-memory fake used in tests, the teamId argument is ignored
// and all requests are returned.'
'' as string,
);
const request = allRequests.find((r) => r.id === requestId);
if (!request) {
this.logger.warn(`Team join request with ID ${requestId} not found`);
throw new Error('Join request not found');
}
const membership: TeamMembership = {
teamId: request.teamId,
driverId: request.driverId,
role: 'driver' as TeamRole,
status: 'active' as TeamMembershipStatus,
joinedAt: new Date(),
};
await this.membershipRepository.saveMembership(membership);
this.logger.info(
`Team membership created for driver ${request.driverId} in team ${request.teamId} from request ${requestId}`,
);
await this.membershipRepository.removeJoinRequest(requestId);
this.logger.info(`Team join request with ID ${requestId} removed`);
} catch (error) {
this.logger.error(`Failed to approve team join request ${requestId}:`, error);
throw error;
}
}
}

View File

@@ -0,0 +1,44 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type { ILogger } from '../../../shared/src/logging/ILogger';
/**
* Use Case: CancelRaceUseCase
*
* Encapsulates the workflow for cancelling a race:
* - loads the race by id
* - throws if the race does not exist
* - delegates cancellation rules to the Race domain entity
* - persists the updated race via the repository.
*/
export interface CancelRaceCommandDTO {
raceId: string;
}
export class CancelRaceUseCase
implements AsyncUseCase<CancelRaceCommandDTO, void> {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly logger: ILogger,
) {}
async execute(command: CancelRaceCommandDTO): Promise<void> {
const { raceId } = command;
this.logger.debug(`[CancelRaceUseCase] Executing for raceId: ${raceId}`);
try {
const race = await this.raceRepository.findById(raceId);
if (!race) {
this.logger.warn(`[CancelRaceUseCase] Race with ID ${raceId} not found.`);
throw new Error('Race not found');
}
const cancelledRace = race.cancel();
await this.raceRepository.update(cancelledRace);
this.logger.info(`[CancelRaceUseCase] Race ${raceId} cancelled successfully.`);
} catch (error) {
this.logger.error(`[CancelRaceUseCase] Error cancelling race ${raceId}:`, error);
throw error;
}
}
}

View File

@@ -0,0 +1,86 @@
import type { UseCase } from '@gridpilot/shared/application/UseCase';
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
import type { IDomainEventPublisher } from '@gridpilot/shared/domain';
import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed';
/**
* Use Case: CloseRaceEventStewardingUseCase
*
* Scheduled job that checks for race events with expired stewarding windows
* and closes them, triggering final results notifications.
*
* This would typically be run by a scheduled job (e.g., every 5 minutes)
* to automatically close stewarding windows based on league configuration.
*/
export interface CloseRaceEventStewardingCommand {
// No parameters needed - finds all expired events automatically
}
export class CloseRaceEventStewardingUseCase
implements UseCase<CloseRaceEventStewardingCommand, void, void, void>
{
constructor(
private readonly raceEventRepository: IRaceEventRepository,
private readonly domainEventPublisher: IDomainEventPublisher,
) {}
async execute(command: CloseRaceEventStewardingCommand): Promise<void> {
// Find all race events awaiting stewarding that have expired windows
const expiredEvents = await this.raceEventRepository.findAwaitingStewardingClose();
for (const raceEvent of expiredEvents) {
await this.closeStewardingForRaceEvent(raceEvent);
}
}
private async closeStewardingForRaceEvent(raceEvent: any): Promise<void> {
try {
// Close the stewarding window
const closedRaceEvent = raceEvent.closeStewarding();
await this.raceEventRepository.update(closedRaceEvent);
// Get list of participating drivers (would need to be implemented)
const driverIds = await this.getParticipatingDriverIds(raceEvent);
// Check if any penalties were applied during stewarding
const hadPenaltiesApplied = await this.checkForAppliedPenalties(raceEvent);
// Publish domain event to trigger final results notifications
const event = new RaceEventStewardingClosedEvent({
raceEventId: raceEvent.id,
leagueId: raceEvent.leagueId,
seasonId: raceEvent.seasonId,
closedAt: new Date(),
driverIds,
hadPenaltiesApplied,
});
await this.domainEventPublisher.publish(event);
} catch (error) {
console.error(`Failed to close stewarding for race event ${raceEvent.id}:`, error);
// In production, this would trigger alerts/monitoring
}
}
private async getParticipatingDriverIds(raceEvent: any): Promise<string[]> {
// In a real implementation, this would query race registrations
// For the prototype, we'll return a mock list
// This would typically involve:
// 1. Get all sessions in the race event
// 2. For each session, get registered drivers
// 3. Return unique driver IDs across all sessions
// Mock implementation for prototype
return ['driver-1', 'driver-2', 'driver-3']; // Would be dynamic in real implementation
}
private async checkForAppliedPenalties(raceEvent: any): Promise<boolean> {
// In a real implementation, this would check if any penalties were issued
// during the stewarding window for this race event
// This would query the penalty repository for penalties related to this race event
// Mock implementation for prototype - randomly simulate penalties
return Math.random() > 0.7; // 30% chance of penalties being applied
}
}

View File

@@ -0,0 +1,188 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
import { Result } from '../../domain/entities/Result';
import { Standing } from '../../domain/entities/Standing';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type { ILogger } from '../../../shared/src/logging/ILogger';
/**
* Use Case: CompleteRaceUseCase
*
* Encapsulates the workflow for completing a race:
* - loads the race by id
* - throws if the race does not exist
* - delegates completion rules to the Race domain entity
* - automatically generates realistic results for registered drivers
* - updates league standings
* - persists all changes via repositories.
*/
export interface CompleteRaceCommandDTO {
raceId: string;
}
export class CompleteRaceUseCase
implements AsyncUseCase<CompleteRaceCommandDTO, void> {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
private readonly resultRepository: IResultRepository,
private readonly standingRepository: IStandingRepository,
private readonly driverRatingProvider: DriverRatingProvider,
private readonly logger: ILogger,
) {}
async execute(command: CompleteRaceCommandDTO): Promise<void> {
this.logger.debug(`Executing CompleteRaceUseCase for raceId: ${command.raceId}`);
const { raceId } = command;
try {
const race = await this.raceRepository.findById(raceId);
if (!race) {
this.logger.error(`Race with id ${raceId} not found.`);
throw new Error('Race not found');
}
this.logger.debug(`Race ${raceId} found. Status: ${race.status}`);
// Get registered drivers for this race
const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId);
if (registeredDriverIds.length === 0) {
this.logger.warn(`No registered drivers found for race ${raceId}.`);
throw new Error('Cannot complete race with no registered drivers');
}
this.logger.info(`${registeredDriverIds.length} drivers registered for race ${raceId}. Generating results.`);
// Get driver ratings
const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds);
this.logger.debug(`Driver ratings fetched for ${registeredDriverIds.length} drivers.`);
// Generate realistic race results
const results = this.generateRaceResults(raceId, registeredDriverIds, driverRatings);
this.logger.debug(`Generated ${results.length} race results for race ${raceId}.`);
// Save results
for (const result of results) {
await this.resultRepository.create(result);
}
this.logger.info(`Persisted ${results.length} race results for race ${raceId}.`);
// Update standings
await this.updateStandings(race.leagueId, results);
this.logger.info(`Standings updated for league ${race.leagueId}.`);
// Complete the race
const completedRace = race.complete();
await this.raceRepository.update(completedRace);
this.logger.info(`Race ${raceId} successfully completed and updated.`);
} catch (error) {
this.logger.error(`Failed to complete race ${raceId}: ${error.message}`, error as Error);
throw error;
}
}
private generateRaceResults(
raceId: string,
driverIds: string[],
driverRatings: Map<string, number>
): Result[] {
this.logger.debug(`Generating race results for race ${raceId} with ${driverIds.length} drivers.`);
// Create driver performance data
const driverPerformances = driverIds.map(driverId => ({
driverId,
rating: driverRatings.get(driverId) ?? 1500, // Default rating
randomFactor: Math.random() - 0.5, // -0.5 to +0.5 randomization
}));
// Sort by performance (rating + randomization)
driverPerformances.sort((a, b) => {
const perfA = a.rating + (a.randomFactor * 200); // ±100 rating points randomization
const perfB = b.rating + (b.randomFactor * 200);
return perfB - perfA; // Higher performance first
});
this.logger.debug(`Driver performances sorted for race ${raceId}.`);
// Generate qualifying results for start positions (similar but different from race results)
const qualiPerformances = driverPerformances.map(p => ({
...p,
randomFactor: Math.random() - 0.5, // New randomization for quali
}));
qualiPerformances.sort((a, b) => {
const perfA = a.rating + (a.randomFactor * 150);
const perfB = b.rating + (b.randomFactor * 150);
return perfB - perfA;
});
this.logger.debug(`Qualifying performances generated for race ${raceId}.`);
// Generate results
const results: Result[] = [];
for (let i = 0; i < driverPerformances.length; i++) {
const { driverId } = driverPerformances[i];
const position = i + 1;
const startPosition = qualiPerformances.findIndex(p => p.driverId === driverId) + 1;
// Generate realistic lap times (90-120 seconds for a lap)
const baseLapTime = 90000 + Math.random() * 30000;
const positionBonus = (position - 1) * 500; // Winners are faster
const fastestLap = Math.round(baseLapTime + positionBonus + Math.random() * 5000);
// Generate incidents (0-3, higher for lower positions)
const incidentProbability = Math.min(0.8, position / driverPerformances.length);
const incidents = Math.random() < incidentProbability ? Math.floor(Math.random() * 3) + 1 : 0;
results.push(
Result.create({
id: `${raceId}-${driverId}`,
raceId,
driverId,
position,
startPosition,
fastestLap,
incidents,
})
);
}
this.logger.debug(`Individual results created for race ${raceId}.`);
return results;
}
private async updateStandings(leagueId: string, results: Result[]): Promise<void> {
this.logger.debug(`Updating standings for league ${leagueId} with ${results.length} results.`);
// Group results by driver
const resultsByDriver = new Map<string, Result[]>();
for (const result of results) {
const existing = resultsByDriver.get(result.driverId) || [];
existing.push(result);
resultsByDriver.set(result.driverId, existing);
}
this.logger.debug(`Results grouped by driver for league ${leagueId}.`);
// Update or create standings for each driver
for (const [driverId, driverResults] of resultsByDriver) {
let standing = await this.standingRepository.findByDriverIdAndLeagueId(driverId, leagueId);
if (!standing) {
standing = Standing.create({
leagueId,
driverId,
});
this.logger.debug(`Created new standing for driver ${driverId} in league ${leagueId}.`);
} else {
this.logger.debug(`Found existing standing for driver ${driverId} in league ${leagueId}.`);
}
// Add all results for this driver (should be just one for this race)
for (const result of driverResults) {
standing = standing.addRaceResult(result.position, {
1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1
});
}
await this.standingRepository.save(standing);
this.logger.debug(`Standing saved for driver ${driverId} in league ${leagueId}.`);
}
this.logger.info(`Standings update complete for league ${leagueId}.`);
}
}

View File

@@ -0,0 +1,132 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
import { Result } from '../../domain/entities/Result';
import { Standing } from '../../domain/entities/Standing';
import { RaceResultGenerator } from '../utils/RaceResultGenerator';
import { RatingUpdateService } from '@gridpilot/identity/domain/services/RatingUpdateService';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type { ILogger } from '../../../shared/src/logging/ILogger';
/**
* Enhanced CompleteRaceUseCase that includes rating updates
*/
export interface CompleteRaceCommandDTO {
raceId: string;
}
export class CompleteRaceUseCaseWithRatings
implements AsyncUseCase<CompleteRaceCommandDTO, void> {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
private readonly resultRepository: IResultRepository,
private readonly standingRepository: IStandingRepository,
private readonly driverRatingProvider: DriverRatingProvider,
private readonly ratingUpdateService: RatingUpdateService,
private readonly logger: ILogger,
) {}
async execute(command: CompleteRaceCommandDTO): Promise<void> {
const { raceId } = command;
this.logger.debug(`Attempting to complete race with ID: ${raceId}`);
try {
const race = await this.raceRepository.findById(raceId);
if (!race) {
this.logger.error(`Race not found for ID: ${raceId}`);
throw new Error('Race not found');
}
this.logger.debug(`Found race: ${race.id}`);
// Get registered drivers for this race
const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId);
if (registeredDriverIds.length === 0) {
this.logger.warn(`No registered drivers for race ID: ${raceId}. Cannot complete race.`);
throw new Error('Cannot complete race with no registered drivers');
}
this.logger.debug(`Found ${registeredDriverIds.length} registered drivers for race ID: ${raceId}`);
// Get driver ratings
this.logger.debug('Fetching driver ratings...');
const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds);
this.logger.debug('Driver ratings fetched.');
// Generate realistic race results
this.logger.debug('Generating race results...');
const results = RaceResultGenerator.generateRaceResults(raceId, registeredDriverIds, driverRatings);
this.logger.info(`Generated ${results.length} race results for race ID: ${raceId}`);
// Save results
this.logger.debug('Saving race results...');
for (const result of results) {
await this.resultRepository.create(result);
}
this.logger.info('Race results saved successfully.');
// Update standings
this.logger.debug(`Updating standings for league ID: ${race.leagueId}`);
await this.updateStandings(race.leagueId, results);
this.logger.info('Standings updated successfully.');
// Update driver ratings based on performance
this.logger.debug('Updating driver ratings...');
await this.updateDriverRatings(results, registeredDriverIds.length);
this.logger.info('Driver ratings updated successfully.');
// Complete the race
this.logger.debug(`Marking race ID: ${raceId} as complete...`);
const completedRace = race.complete();
await this.raceRepository.update(completedRace);
this.logger.info(`Race ID: ${raceId} completed successfully.`);
} catch (error: any) {
this.logger.error(`Error completing race ${raceId}: ${error.message}`);
throw error;
}
}
private async updateStandings(leagueId: string, results: Result[]): Promise<void> {
// Group results by driver
const resultsByDriver = new Map<string, Result[]>();
for (const result of results) {
const existing = resultsByDriver.get(result.driverId) || [];
existing.push(result);
resultsByDriver.set(result.driverId, existing);
}
// Update or create standings for each driver
for (const [driverId, driverResults] of resultsByDriver) {
let standing = await this.standingRepository.findByDriverIdAndLeagueId(driverId, leagueId);
if (!standing) {
standing = Standing.create({
leagueId,
driverId,
});
}
// Add all results for this driver (should be just one for this race)
for (const result of driverResults) {
standing = standing.addRaceResult(result.position, {
1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1
});
}
await this.standingRepository.save(standing);
}
}
private async updateDriverRatings(results: Result[], totalDrivers: number): Promise<void> {
const driverResults = results.map(result => ({
driverId: result.driverId,
position: result.position,
totalDrivers,
incidents: result.incidents,
startPosition: result.startPosition,
}));
await this.ratingUpdateService.updateDriverRatingsAfterRace(driverResults);
}
}

View File

@@ -0,0 +1,204 @@
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';
import type { LeagueScoringConfig } from '../../domain/entities/LeagueScoringConfig';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type { ILogger } from '../../../shared/src/logging/ILogger';
import type {
LeagueScoringPresetProvider,
LeagueScoringPresetDTO,
} from '../ports/LeagueScoringPresetProvider';
import {
LeagueVisibility,
MIN_RANKED_LEAGUE_DRIVERS,
} from '../../domain/value-objects/LeagueVisibility';
/**
* League visibility/ranking mode.
* - 'ranked' (or legacy 'public'): Competitive, public, affects driver ratings. Min 10 drivers.
* - 'unranked' (or legacy 'private'): Casual with friends, no rating impact.
*/
export type LeagueVisibilityInput = 'ranked' | 'unranked' | 'public' | 'private';
export interface CreateLeagueWithSeasonAndScoringCommand {
name: string;
description?: string;
/**
* League visibility/ranking mode.
* - 'ranked' (or legacy 'public'): Competitive, public, affects ratings. Requires min 10 drivers.
* - 'unranked' (or legacy 'private'): Casual with friends, no rating impact.
*/
visibility: LeagueVisibilityInput;
ownerId: string;
gameId: string;
maxDrivers?: number;
maxTeams?: number;
enableDriverChampionship: boolean;
enableTeamChampionship: boolean;
enableNationsChampionship: boolean;
enableTrophyChampionship: boolean;
scoringPresetId?: string;
}
export interface CreateLeagueWithSeasonAndScoringResultDTO {
leagueId: string;
seasonId: string;
scoringPresetId?: string;
scoringPresetName?: string;
}
export class CreateLeagueWithSeasonAndScoringUseCase
implements AsyncUseCase<CreateLeagueWithSeasonAndScoringCommand, CreateLeagueWithSeasonAndScoringResultDTO> {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
private readonly presetProvider: LeagueScoringPresetProvider,
) {}
async execute(
command: CreateLeagueWithSeasonAndScoringCommand,
): Promise<CreateLeagueWithSeasonAndScoringResultDTO> {
this.logger.debug('Executing CreateLeagueWithSeasonAndScoringUseCase', { command });
try {
this.validate(command);
this.logger.info('Command validated successfully.');
const leagueId = uuidv4();
this.logger.debug(`Generated leagueId: ${leagueId}`);
const league = League.create({
id: leagueId,
name: command.name,
description: command.description ?? '',
ownerId: command.ownerId,
settings: {
pointsSystem: 'custom',
...(command.maxDrivers !== undefined ? { maxDrivers: command.maxDrivers } : {}),
},
});
await this.leagueRepository.create(league);
this.logger.info(`League ${league.name} (${league.id}) created successfully.`);
const seasonId = uuidv4();
this.logger.debug(`Generated seasonId: ${seasonId}`);
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',
startDate: new Date(),
endDate: new Date(),
});
await this.seasonRepository.create(season);
this.logger.info(`Season ${season.name} (${season.id}) created for league ${league.id}.`);
const presetId = command.scoringPresetId ?? 'club-default';
this.logger.debug(`Attempting to retrieve scoring preset: ${presetId}`);
const preset: LeagueScoringPresetDTO | undefined =
this.presetProvider.getPresetById(presetId);
if (!preset) {
this.logger.error(`Unknown scoring preset: ${presetId}`);
throw new Error(`Unknown scoring preset: ${presetId}`);
}
this.logger.info(`Scoring preset ${preset.name} (${preset.id}) retrieved.`);
const scoringConfig: LeagueScoringConfig = {
id: uuidv4(),
seasonId,
scoringPresetId: preset.id,
championships: [],
};
const fullConfigFactory = (await import(
'../../infrastructure/repositories/InMemoryScoringRepositories'
)) as typeof import('../../infrastructure/repositories/InMemoryScoringRepositories');
const presetFromInfra = fullConfigFactory.getLeagueScoringPresetById(
preset.id,
);
if (!presetFromInfra) {
this.logger.error(`Preset registry missing preset: ${preset.id}`);
throw new Error(`Preset registry missing preset: ${preset.id}`);
}
this.logger.debug(`Preset from infrastructure retrieved for ${preset.id}.`);
const infraConfig = presetFromInfra.createConfig({ seasonId });
const finalConfig: LeagueScoringConfig = {
...infraConfig,
scoringPresetId: preset.id,
};
await this.leagueScoringConfigRepository.save(finalConfig);
this.logger.info(`Scoring configuration saved for season ${seasonId}.`);
const result = {
leagueId: league.id,
seasonId,
scoringPresetId: preset.id,
scoringPresetName: preset.name,
};
this.logger.debug('CreateLeagueWithSeasonAndScoringUseCase completed successfully.', { result });
return result;
} catch (error: any) {
this.logger.error('Error during CreateLeagueWithSeasonAndScoringUseCase execution.', {
command,
error: error.message,
stack: error.stack,
});
throw error;
}
}
private validate(command: CreateLeagueWithSeasonAndScoringCommand): void {
this.logger.debug('Validating CreateLeagueWithSeasonAndScoringCommand', { command });
if (!command.name || command.name.trim().length === 0) {
this.logger.warn('Validation failed: League name is required', { command });
throw new Error('League name is required');
}
if (!command.ownerId || command.ownerId.trim().length === 0) {
this.logger.warn('Validation failed: League ownerId is required', { command });
throw new Error('League ownerId is required');
}
if (!command.gameId || command.gameId.trim().length === 0) {
this.logger.warn('Validation failed: gameId is required', { command });
throw new Error('gameId is required');
}
if (!command.visibility) {
this.logger.warn('Validation failed: visibility is required', { command });
throw new Error('visibility is required');
}
if (command.maxDrivers !== undefined && command.maxDrivers <= 0) {
this.logger.warn('Validation failed: maxDrivers must be greater than 0 when provided', { command });
throw new Error('maxDrivers must be greater than 0 when provided');
}
const visibility = LeagueVisibility.fromString(command.visibility);
if (visibility.isRanked()) {
const driverCount = command.maxDrivers ?? 0;
if (driverCount < MIN_RANKED_LEAGUE_DRIVERS) {
this.logger.warn(
`Validation failed: Ranked leagues require at least ${MIN_RANKED_LEAGUE_DRIVERS} drivers. Current setting: ${driverCount}.`,
{ command }
);
throw new Error(
`Ranked leagues require at least ${MIN_RANKED_LEAGUE_DRIVERS} drivers. ` +
`Current setting: ${driverCount}. ` +
`For smaller groups, consider creating an Unranked (Friends) league instead.`
);
}
}
this.logger.debug('Validation successful.');
}
}

View File

@@ -0,0 +1,53 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import { Team } from '../../domain/entities/Team';
import type {
TeamMembership,
TeamMembershipStatus,
TeamRole,
} from '../../domain/types/TeamMembership';
import type {
CreateTeamCommandDTO,
CreateTeamResultDTO,
} from '../dto/CreateTeamCommandDTO';
export class CreateTeamUseCase {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository,
) {}
async execute(command: CreateTeamCommandDTO): Promise<CreateTeamResultDTO> {
const { name, tag, description, ownerId, leagues } = command;
const existingMembership = await this.membershipRepository.getActiveMembershipForDriver(
ownerId,
);
if (existingMembership) {
throw new Error('Driver already belongs to a team');
}
const team = Team.create({
id: `team-${Date.now()}`,
name,
tag,
description,
ownerId,
leagues,
});
const createdTeam = await this.teamRepository.create(team);
const membership: TeamMembership = {
teamId: createdTeam.id,
driverId: ownerId,
role: 'owner' as TeamRole,
status: 'active' as TeamMembershipStatus,
joinedAt: new Date(),
};
await this.membershipRepository.saveMembership(membership);
return { team: createdTeam };
}
}

View File

@@ -0,0 +1,68 @@
/**
* Application Use Case: FileProtestUseCase
*
* Allows a driver to file a protest against another driver for an incident during a race.
*/
import { Protest, type ProtestIncident } from '../../domain/entities/Protest';
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { randomUUID } from 'crypto';
export interface FileProtestCommand {
raceId: string;
protestingDriverId: string;
accusedDriverId: string;
incident: ProtestIncident;
comment?: string;
proofVideoUrl?: string;
}
export class FileProtestUseCase {
constructor(
private readonly protestRepository: IProtestRepository,
private readonly raceRepository: IRaceRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
) {}
async execute(command: FileProtestCommand): Promise<{ protestId: string }> {
// Validate race exists
const race = await this.raceRepository.findById(command.raceId);
if (!race) {
throw new Error('Race not found');
}
// Validate drivers are not the same
if (command.protestingDriverId === command.accusedDriverId) {
throw new Error('Cannot file a protest against yourself');
}
// Validate protesting driver is a member of the league
const memberships = await this.leagueMembershipRepository.getLeagueMembers(race.leagueId);
const protestingDriverMembership = memberships.find(
m => m.driverId === command.protestingDriverId && m.status === 'active'
);
if (!protestingDriverMembership) {
throw new Error('Protesting driver is not an active member of this league');
}
// Create the protest
const protest = Protest.create({
id: randomUUID(),
raceId: command.raceId,
protestingDriverId: command.protestingDriverId,
accusedDriverId: command.accusedDriverId,
incident: command.incident,
...(command.comment !== undefined ? { comment: command.comment } : {}),
...(command.proofVideoUrl !== undefined ? { proofVideoUrl: command.proofVideoUrl } : {}),
status: 'pending',
filedAt: new Date(),
});
await this.protestRepository.create(protest);
return { protestId: protest.id };
}
}

View File

@@ -0,0 +1,94 @@
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider';
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
UseCase<
void,
LeagueEnrichedData[],
AllLeaguesWithCapacityAndScoringViewModel,
IAllLeaguesWithCapacityAndScoringPresenter
>
{
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
private readonly gameRepository: IGameRepository,
private readonly presetProvider: LeagueScoringPresetProvider,
) {}
async execute(
_input: void,
presenter: IAllLeaguesWithCapacityAndScoringPresenter,
): Promise<void> {
presenter.reset();
const leagues = await this.leagueRepository.findAll();
const enrichedLeagues: LeagueEnrichedData[] = [];
for (const league of leagues) {
const members = await this.leagueMembershipRepository.getLeagueMembers(league.id);
const usedDriverSlots = members.filter(
(m) =>
m.status === 'active' &&
(m.role === 'owner' ||
m.role === 'admin' ||
m.role === 'steward' ||
m.role === 'member'),
).length;
const seasons = await this.seasonRepository.findByLeagueId(league.id);
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) {
const scoringConfigResult =
await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
scoringConfig = scoringConfigResult ?? undefined;
if (scoringConfig) {
const gameResult = await this.gameRepository.findById(activeSeason.gameId);
game = gameResult ?? undefined;
const presetId = scoringConfig.scoringPresetId;
if (presetId) {
preset = this.presetProvider.getPresetById(presetId);
}
}
}
enrichedLeagues.push({
league,
usedDriverSlots,
...(activeSeason ? { season: activeSeason } : {}),
...(scoringConfig ? { scoringConfig } : {}),
...(game ? { game } : {}),
...(preset ? { preset } : {}),
});
}
presenter.present(enrichedLeagues);
}
}

View File

@@ -0,0 +1,54 @@
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
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 UseCase<void, AllLeaguesWithCapacityResultDTO, AllLeaguesWithCapacityViewModel, IAllLeaguesWithCapacityPresenter>
{
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
) {}
async execute(
_input: void,
presenter: IAllLeaguesWithCapacityPresenter,
): Promise<void> {
presenter.reset();
const leagues = await this.leagueRepository.findAll();
const memberCounts = new Map<string, number>();
for (const league of leagues) {
const members = await this.leagueMembershipRepository.getLeagueMembers(league.id);
const usedSlots = members.filter(
(m) =>
m.status === 'active' &&
(m.role === 'owner' ||
m.role === 'admin' ||
m.role === 'steward' ||
m.role === 'member'),
).length;
memberCounts.set(league.id, usedSlots);
}
const dto: AllLeaguesWithCapacityResultDTO = {
leagues,
memberCounts,
};
presenter.present(dto);
}
}

View File

@@ -0,0 +1,75 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILogger } from '../../../shared/src/logging/ILogger';
import type {
IAllRacesPagePresenter,
AllRacesPageResultDTO,
AllRacesPageViewModel,
AllRacesListItemViewModel,
AllRacesFilterOptionsViewModel,
} from '../presenters/IAllRacesPagePresenter';
import type { UseCase } => '@gridpilot/shared/application';
export class GetAllRacesPageDataUseCase
implements UseCase<void, AllRacesPageResultDTO, AllRacesPageViewModel, IAllRacesPagePresenter> {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly logger: ILogger,
) {}
async execute(_input: void, presenter: IAllRacesPagePresenter): Promise<void> {
this.logger.debug('Executing GetAllRacesPageDataUseCase');
try {
const [allRaces, allLeagues] = await Promise.all([
this.raceRepository.findAll(),
this.leagueRepository.findAll(),
]);
this.logger.info(`Found ${allRaces.length} races and ${allLeagues.length} leagues.`);
const leagueMap = new Map(allLeagues.map((league) => [league.id, league.name]));
const races: AllRacesListItemViewModel[] = allRaces
.slice()
.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime())
.map((race) => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt.toISOString(),
status: race.status,
leagueId: race.leagueId,
leagueName: leagueMap.get(race.leagueId) ?? 'Unknown League',
strengthOfField: race.strengthOfField ?? null,
}));
const uniqueLeagues = new Map<string, { id: string; name: string }>();
for (const league of allLeagues) {
uniqueLeagues.set(league.id, { id: league.id, name: league.name });
}
const filters: AllRacesFilterOptionsViewModel = {
statuses: [
{ value: 'all', label: 'All Statuses' },
{ value: 'scheduled', label: 'Scheduled' },
{ value: 'running', label: 'Live' },
{ value: 'completed', label: 'Completed' },
{ value: 'cancelled', label: 'Cancelled' },
],
leagues: Array.from(uniqueLeagues.values()),
};
const viewModel: AllRacesPageViewModel = {
races,
filters,
};
presenter.reset();
presenter.present(viewModel);
this.logger.debug('Successfully presented all races page data.');
} catch (error) {
this.logger.error('Error executing GetAllRacesPageDataUseCase', { error });
throw error;
}
}
}

View File

@@ -0,0 +1,61 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type {
IAllTeamsPresenter,
AllTeamsResultDTO,
} from '../presenters/IAllTeamsPresenter';
import type { UseCase } from '@gridpilot/shared/application';
import type { Team } from '../../domain/entities/Team';
import { ILogger } from '../../../shared/src/logging/ILogger';
/**
* Use Case for retrieving all teams.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetAllTeamsUseCase
implements UseCase<void, AllTeamsResultDTO, import('../presenters/IAllTeamsPresenter').AllTeamsViewModel, IAllTeamsPresenter>
{
constructor(
private readonly teamRepository: ITeamRepository,
private readonly teamMembershipRepository: ITeamMembershipRepository,
private readonly logger: ILogger,
) {}
async execute(_input: void, presenter: IAllTeamsPresenter): Promise<void> {
this.logger.debug('Executing GetAllTeamsUseCase');
presenter.reset();
try {
const teams = await this.teamRepository.findAll();
if (teams.length === 0) {
this.logger.warn('No teams found.');
}
const enrichedTeams: AllTeamsResultDTO['teams'] = await Promise.all(
teams.map(async (team) => {
const memberCount = await this.teamMembershipRepository.countByTeamId(team.id);
return {
id: team.id,
name: team.name,
tag: team.tag,
description: team.description,
ownerId: team.ownerId,
leagues: [...team.leagues],
createdAt: team.createdAt,
memberCount,
};
}),
);
const dto: AllTeamsResultDTO = {
teams: enrichedTeams,
};
presenter.present(dto);
this.logger.info('Successfully retrieved all teams.');
} catch (error) {
this.logger.error('Error retrieving all teams:', error);
throw error; // Re-throw the error after logging
}
}
}

View File

@@ -0,0 +1,308 @@
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
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 {
IDashboardOverviewPresenter,
DashboardOverviewViewModel,
DashboardDriverSummaryViewModel,
DashboardRaceSummaryViewModel,
DashboardRecentResultViewModel,
DashboardLeagueStandingSummaryViewModel,
DashboardFeedItemSummaryViewModel,
DashboardFeedSummaryViewModel,
DashboardFriendSummaryViewModel,
} from '../presenters/IDashboardOverviewPresenter';
interface DashboardDriverStatsAdapter {
rating: number | null;
wins: number;
podiums: number;
totalRaces: number;
overallRank: number | null;
consistency: number | null;
}
export interface GetDashboardOverviewParams {
driverId: string;
}
export class GetDashboardOverviewUseCase {
constructor(
private readonly driverRepository: IDriverRepository,
private readonly raceRepository: IRaceRepository,
private readonly resultRepository: IResultRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly standingRepository: IStandingRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
private readonly feedRepository: IFeedRepository,
private readonly socialRepository: ISocialGraphRepository,
private readonly imageService: IImageServicePort,
private readonly getDriverStats: (driverId: string) => DashboardDriverStatsAdapter | null,
) {}
async execute(params: GetDashboardOverviewParams, presenter: IDashboardOverviewPresenter): Promise<void> {
const { driverId } = params;
const [driver, allLeagues, allRaces, allResults, feedItems, friends] = await Promise.all([
this.driverRepository.findById(driverId),
this.leagueRepository.findAll(),
this.raceRepository.findAll(),
this.resultRepository.findAll(),
this.feedRepository.getFeedForDriver(driverId),
this.socialRepository.getFriends(driverId),
]);
const leagueMap = new Map(allLeagues.map(league => [league.id, league.name]));
const driverStats = this.getDriverStats(driverId);
const currentDriver: DashboardDriverSummaryViewModel | null = driver
? {
id: driver.id,
name: driver.name,
country: driver.country,
avatarUrl: this.imageService.getDriverAvatar(driver.id),
rating: driverStats?.rating ?? null,
globalRank: driverStats?.overallRank ?? null,
totalRaces: driverStats?.totalRaces ?? 0,
wins: driverStats?.wins ?? 0,
podiums: driverStats?.podiums ?? 0,
consistency: driverStats?.consistency ?? null,
}
: null;
const driverLeagues = await this.getDriverLeagues(allLeagues, driverId);
const driverLeagueIds = new Set(driverLeagues.map(league => league.id));
const now = new Date();
const upcomingRaces = allRaces
.filter(race => race.status === 'scheduled' && race.scheduledAt > now)
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
const upcomingRacesInDriverLeagues = upcomingRaces.filter(race =>
driverLeagueIds.has(race.leagueId),
);
const { myUpcomingRaces, otherUpcomingRaces } =
await this.partitionUpcomingRacesByRegistration(upcomingRacesInDriverLeagues, driverId, leagueMap);
const nextRace: DashboardRaceSummaryViewModel | null =
myUpcomingRaces.length > 0 ? myUpcomingRaces[0]! : null;
const upcomingRacesSummaries: DashboardRaceSummaryViewModel[] = [
...myUpcomingRaces,
...otherUpcomingRaces,
].slice().sort(
(a, b) =>
new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime(),
);
const recentResults = this.buildRecentResults(allResults, allRaces, allLeagues, driverId);
const leagueStandingsSummaries = await this.buildLeagueStandingsSummaries(
driverLeagues,
driverId,
);
const activeLeaguesCount = this.computeActiveLeaguesCount(
upcomingRacesSummaries,
leagueStandingsSummaries,
);
const feedSummary = this.buildFeedSummary(feedItems);
const friendsSummary = this.buildFriendsSummary(friends);
const viewModel: DashboardOverviewViewModel = {
currentDriver,
myUpcomingRaces,
otherUpcomingRaces,
upcomingRaces: upcomingRacesSummaries,
activeLeaguesCount,
nextRace,
recentResults,
leagueStandingsSummaries,
feedSummary,
friends: friendsSummary,
};
presenter.reset();
presenter.present(viewModel);
}
private async getDriverLeagues(allLeagues: any[], driverId: string): Promise<any[]> {
const driverLeagues: any[] = [];
for (const league of allLeagues) {
const membership = await this.leagueMembershipRepository.getMembership(league.id, driverId);
if (membership && membership.status === 'active') {
driverLeagues.push(league);
}
}
return driverLeagues;
}
private async partitionUpcomingRacesByRegistration(
upcomingRaces: any[],
driverId: string,
leagueMap: Map<string, string>,
): Promise<{
myUpcomingRaces: DashboardRaceSummaryViewModel[];
otherUpcomingRaces: DashboardRaceSummaryViewModel[];
}> {
const myUpcomingRaces: DashboardRaceSummaryViewModel[] = [];
const otherUpcomingRaces: DashboardRaceSummaryViewModel[] = [];
for (const race of upcomingRaces) {
const isRegistered = await this.raceRegistrationRepository.isRegistered(race.id, driverId);
const summary = this.mapRaceToSummary(race, leagueMap, true);
if (isRegistered) {
myUpcomingRaces.push(summary);
} else {
otherUpcomingRaces.push(summary);
}
}
return { myUpcomingRaces, otherUpcomingRaces };
}
private mapRaceToSummary(
race: any,
leagueMap: Map<string, string>,
isMyLeague: boolean,
): DashboardRaceSummaryViewModel {
return {
id: race.id,
leagueId: race.leagueId,
leagueName: leagueMap.get(race.leagueId) ?? 'Unknown League',
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt.toISOString(),
status: race.status,
isMyLeague,
};
}
private buildRecentResults(
allResults: any[],
allRaces: any[],
allLeagues: any[],
driverId: string,
): DashboardRecentResultViewModel[] {
const raceById = new Map(allRaces.map(race => [race.id, race]));
const leagueById = new Map(allLeagues.map(league => [league.id, league]));
const driverResults = allResults.filter(result => result.driverId === driverId);
const enriched = driverResults
.map(result => {
const race = raceById.get(result.raceId);
if (!race) return null;
const league = leagueById.get(race.leagueId);
const finishedAt = race.scheduledAt.toISOString();
const item: DashboardRecentResultViewModel = {
raceId: race.id,
raceName: race.track,
leagueId: race.leagueId,
leagueName: league?.name ?? 'Unknown League',
finishedAt,
position: result.position,
incidents: result.incidents,
};
return item;
})
.filter((item): item is DashboardRecentResultViewModel => !!item)
.sort(
(a, b) =>
new Date(b.finishedAt).getTime() - new Date(a.finishedAt).getTime(),
);
const RECENT_RESULTS_LIMIT = 5;
return enriched.slice(0, RECENT_RESULTS_LIMIT);
}
private async buildLeagueStandingsSummaries(
driverLeagues: any[],
driverId: string,
): Promise<DashboardLeagueStandingSummaryViewModel[]> {
const summaries: DashboardLeagueStandingSummaryViewModel[] = [];
for (const league of driverLeagues.slice(0, 3)) {
const standings = await this.standingRepository.findByLeagueId(league.id);
const driverStanding = standings.find(
(standing: any) => standing.driverId === driverId,
);
summaries.push({
leagueId: league.id,
leagueName: league.name,
position: driverStanding?.position ?? 0,
points: driverStanding?.points ?? 0,
totalDrivers: standings.length,
});
}
return summaries;
}
private computeActiveLeaguesCount(
upcomingRaces: DashboardRaceSummaryViewModel[],
leagueStandingsSummaries: DashboardLeagueStandingSummaryViewModel[],
): number {
const activeLeagueIds = new Set<string>();
for (const race of upcomingRaces) {
activeLeagueIds.add(race.leagueId);
}
for (const standing of leagueStandingsSummaries) {
activeLeagueIds.add(standing.leagueId);
}
return activeLeagueIds.size;
}
private buildFeedSummary(feedItems: any[]): DashboardFeedSummaryViewModel {
const items: DashboardFeedItemSummaryViewModel[] = feedItems.map(item => ({
id: item.id,
type: item.type,
headline: item.headline,
body: item.body,
timestamp:
item.timestamp instanceof Date
? item.timestamp.toISOString()
: new Date(item.timestamp).toISOString(),
ctaLabel: item.ctaLabel,
ctaHref: item.ctaHref,
}));
return {
notificationCount: items.length,
items,
};
}
private buildFriendsSummary(friends: any[]): DashboardFriendSummaryViewModel[] {
return friends.map(friend => ({
id: friend.id,
name: friend.name,
country: friend.country,
avatarUrl: this.imageService.getDriverAvatar(friend.id),
}));
}
}

View File

@@ -0,0 +1,54 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type {
IDriverTeamPresenter,
DriverTeamResultDTO,
DriverTeamViewModel,
} from '../presenters/IDriverTeamPresenter';
import type { UseCase } from '@gridpilot/shared/application';
import type { ILogger } from '../../../shared/src/logging/ILogger';
/**
* Use Case for retrieving a driver's team.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetDriverTeamUseCase
implements UseCase<{ driverId: string }, DriverTeamResultDTO, DriverTeamViewModel, IDriverTeamPresenter>
{
constructor(
private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository,
private readonly logger: ILogger,
// 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(input: { driverId: string }, presenter: IDriverTeamPresenter): Promise<void> {
this.logger.debug(`Executing GetDriverTeamUseCase for driverId: ${input.driverId}`);
presenter.reset();
const membership = await this.membershipRepository.getActiveMembershipForDriver(input.driverId);
if (!membership) {
this.logger.warn(`No active membership found for driverId: ${input.driverId}`);
return;
}
this.logger.debug(`Found membership for driverId: ${input.driverId}, teamId: ${membership.teamId}`);
const team = await this.teamRepository.findById(membership.teamId);
if (!team) {
this.logger.error(`Team not found for teamId: ${membership.teamId}`);
return;
}
this.logger.debug(`Found team for teamId: ${team.id}, name: ${team.name}`);
const dto: DriverTeamResultDTO = {
team,
membership,
driverId: input.driverId,
};
presenter.present(dto);
this.logger.info(`Successfully presented driver team for driverId: ${input.driverId}`);
}
}

View File

@@ -0,0 +1,52 @@
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IRankingService } from '../../domain/services/IRankingService';
import type { IDriverStatsService } from '../../domain/services/IDriverStatsService';
import type { IImageServicePort } from '../ports/IImageServicePort';
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 UseCase<void, DriversLeaderboardResultDTO, DriversLeaderboardViewModel, IDriversLeaderboardPresenter>
{
constructor(
private readonly driverRepository: IDriverRepository,
private readonly rankingService: IRankingService,
private readonly driverStatsService: IDriverStatsService,
private readonly imageService: IImageServicePort,
) {}
async execute(_input: void, presenter: IDriversLeaderboardPresenter): Promise<void> {
presenter.reset();
const drivers = await this.driverRepository.findAll();
const rankings = this.rankingService.getAllDriverRankings();
const stats: DriversLeaderboardResultDTO['stats'] = {};
const avatarUrls: DriversLeaderboardResultDTO['avatarUrls'] = {};
for (const driver of drivers) {
const driverStats = this.driverStatsService.getDriverStats(driver.id);
if (driverStats) {
stats[driver.id] = driverStats;
}
avatarUrls[driver.id] = this.imageService.getDriverAvatar(driver.id);
}
const dto: DriversLeaderboardResultDTO = {
drivers,
rankings,
stats,
avatarUrls,
};
presenter.present(dto);
}
}

View File

@@ -0,0 +1,161 @@
/**
* Application Use Case: GetEntitySponsorshipPricingUseCase
*
* Retrieves sponsorship pricing configuration for any entity.
* Used by sponsors to see available slots and prices.
*/
import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository';
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship';
import type { IEntitySponsorshipPricingPresenter } from '../presenters/IEntitySponsorshipPricingPresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type { ILogger } from '../../../shared/src/logging/ILogger';
export interface GetEntitySponsorshipPricingDTO {
entityType: SponsorableEntityType;
entityId: string;
}
export interface SponsorshipSlotDTO {
tier: SponsorshipTier;
price: number;
currency: string;
formattedPrice: string;
benefits: string[];
available: boolean;
maxSlots: number;
filledSlots: number;
pendingRequests: number;
}
export interface GetEntitySponsorshipPricingResultDTO {
entityType: SponsorableEntityType;
entityId: string;
acceptingApplications: boolean;
customRequirements?: string;
mainSlot?: SponsorshipSlotDTO;
secondarySlot?: SponsorshipSlotDTO;
}
export class GetEntitySponsorshipPricingUseCase
implements AsyncUseCase<GetEntitySponsorshipPricingDTO, void> {
constructor(
private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository,
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,
private readonly presenter: IEntitySponsorshipPricingPresenter,
private readonly logger: ILogger,
) {}
async execute(dto: GetEntitySponsorshipPricingDTO): Promise<void> {
this.logger.debug(
`Executing GetEntitySponsorshipPricingUseCase for entityType: ${dto.entityType}, entityId: ${dto.entityId}`,
{ dto },
);
try {
const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId);
if (!pricing) {
this.logger.warn(
`No pricing found for entityType: ${dto.entityType}, entityId: ${dto.entityId}. Presenting null.`,
{ dto },
);
this.presenter.present(null);
return;
}
this.logger.debug(`Found pricing for entityType: ${dto.entityType}, entityId: ${dto.entityId}`, { pricing });
// Count pending requests by tier
const pendingRequests = await this.sponsorshipRequestRepo.findPendingByEntity(
dto.entityType,
dto.entityId,
);
const pendingMainCount = pendingRequests.filter(r => r.tier === 'main').length;
const pendingSecondaryCount = pendingRequests.filter(r => r.tier === 'secondary').length;
this.logger.debug(
`Pending requests counts: main=${pendingMainCount}, secondary=${pendingSecondaryCount}`,
);
// Count filled slots (for seasons, check SeasonSponsorship table)
let filledMainSlots = 0;
let filledSecondarySlots = 0;
if (dto.entityType === 'season') {
const sponsorships = await this.seasonSponsorshipRepo.findBySeasonId(dto.entityId);
const activeSponsorships = sponsorships.filter(s => s.isActive());
filledMainSlots = activeSponsorships.filter(s => s.tier === 'main').length;
filledSecondarySlots = activeSponsorships.filter(s => s.tier === 'secondary').length;
this.logger.debug(
`Filled slots for season: main=${filledMainSlots}, secondary=${filledSecondarySlots}`,
);
}
const result: GetEntitySponsorshipPricingResultDTO = {
entityType: dto.entityType,
entityId: dto.entityId,
acceptingApplications: pricing.acceptingApplications,
...(pricing.customRequirements !== undefined
? { customRequirements: pricing.customRequirements }
: {}),
};
if (pricing.mainSlot) {
const mainMaxSlots = pricing.mainSlot.maxSlots;
result.mainSlot = {
tier: 'main',
price: pricing.mainSlot.price.amount,
currency: pricing.mainSlot.price.currency,
formattedPrice: pricing.mainSlot.price.format(),
benefits: pricing.mainSlot.benefits,
available: pricing.mainSlot.available && filledMainSlots < mainMaxSlots,
maxSlots: mainMaxSlots,
filledSlots: filledMainSlots,
pendingRequests: pendingMainCount,
};
this.logger.debug(`Main slot pricing information processed`, { mainSlot: result.mainSlot });
}
if (pricing.secondarySlots) {
const secondaryMaxSlots = pricing.secondarySlots.maxSlots;
result.secondarySlot = {
tier: 'secondary',
price: pricing.secondarySlots.price.amount,
currency: pricing.secondarySlots.price.currency,
formattedPrice: pricing.secondarySlots.price.format(),
benefits: pricing.secondarySlots.benefits,
available:
pricing.secondarySlots.available && filledSecondarySlots < secondaryMaxSlots,
maxSlots: secondaryMaxSlots,
filledSlots: filledSecondarySlots,
pendingRequests: pendingSecondaryCount,
};
this.logger.debug(`Secondary slot pricing information processed`, {
secondarySlot: result.secondarySlot,
});
}
this.logger.info(
`Successfully retrieved and processed entity sponsorship pricing for entityType: ${dto.entityType}, entityId: ${dto.entityId}`,
{ result },
);
this.presenter.present(result);
} catch (error: unknown) {
let errorMessage = 'An unknown error occurred';
if (error instanceof Error) {
errorMessage = error.message;
}
this.logger.error(
`Failed to get entity sponsorship pricing for entityType: ${dto.entityType}, entityId: ${dto.entityId}. Error: ${errorMessage}`,
{ error, dto },
);
// Re-throw the error or present an error state if the presenter supports it
throw error;
}
}
}

View File

@@ -0,0 +1,109 @@
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
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 };
}
export interface GetLeagueDriverSeasonStatsUseCaseParams {
leagueId: string;
}
/**
* Use Case for retrieving league driver season statistics.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetLeagueDriverSeasonStatsUseCase
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,
) {}
async execute(
params: GetLeagueDriverSeasonStatsUseCaseParams,
presenter: ILeagueDriverSeasonStatsPresenter,
): Promise<void> {
presenter.reset();
const { leagueId } = params;
// Get standings and races for the league
const [standings, races] = await Promise.all([
this.standingRepository.findByLeagueId(leagueId),
this.raceRepository.findByLeagueId(leagueId),
]);
// Fetch all penalties for all races in the league
const penaltiesArrays = await Promise.all(
races.map(race => this.penaltyRepository.findByRaceId(race.id))
);
const penaltiesForLeague = penaltiesArrays.flat();
// Group penalties by driver for quick lookup
const penaltiesByDriver = new Map<string, { baseDelta: number; bonusDelta: number }>();
for (const p of penaltiesForLeague) {
// Only count applied penalties
if (p.status !== 'applied') continue;
const current = penaltiesByDriver.get(p.driverId) ?? { baseDelta: 0, bonusDelta: 0 };
// Convert penalty to points delta based on type
if (p.type === 'points_deduction' && p.value) {
// Points deductions are negative
current.baseDelta -= p.value;
}
penaltiesByDriver.set(p.driverId, current);
}
// Collect driver ratings
const driverRatings = new Map<string, { rating: number | null; ratingChange: number | null }>();
for (const standing of standings) {
const ratingInfo = this.driverRatingPort.getRating(standing.driverId);
driverRatings.set(standing.driverId, ratingInfo);
}
// Collect driver results
const driverResults = new Map<string, Array<{ position: number }>>();
for (const standing of standings) {
const results = await this.resultRepository.findByDriverIdAndLeagueId(
standing.driverId,
leagueId,
);
driverResults.set(standing.driverId, results);
}
const dto: LeagueDriverSeasonStatsResultDTO = {
leagueId,
standings: standings.map(standing => ({
driverId: standing.driverId,
position: standing.position,
points: standing.points,
racesCompleted: standing.racesCompleted,
})),
penalties: penaltiesByDriver,
driverResults,
driverRatings,
};
presenter.present(dto);
}
}

View File

@@ -0,0 +1,60 @@
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
import type {
ILeagueFullConfigPresenter,
LeagueFullConfigData,
LeagueConfigFormViewModel,
} from '../presenters/ILeagueFullConfigPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
import { EntityNotFoundError } from '../errors/RacingApplicationError';
/**
* Use Case for retrieving a league's full configuration.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetLeagueFullConfigUseCase
implements UseCase<{ leagueId: string }, LeagueFullConfigData, LeagueConfigFormViewModel, ILeagueFullConfigPresenter>
{
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
private readonly gameRepository: IGameRepository,
) {}
async execute(params: { leagueId: string }, presenter: ILeagueFullConfigPresenter): Promise<void> {
const { leagueId } = params;
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
throw new EntityNotFoundError({ entity: 'league', id: leagueId });
}
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
const activeSeason =
seasons && seasons.length > 0
? seasons.find((s) => s.status === 'active') ?? seasons[0]
: undefined;
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 ? { activeSeason } : {}),
...(scoringConfig ? { scoringConfig } : {}),
...(game ? { game } : {}),
};
presenter.reset();
presenter.present(data);
}
}

View File

@@ -0,0 +1,75 @@
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider';
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 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,
) {}
async execute(params: { leagueId: string }, presenter: ILeagueScoringConfigPresenter): Promise<void> {
const { leagueId } = params;
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
throw new Error(`League ${leagueId} not found`);
}
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
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) {
throw new Error(`No scoring config found for season ${activeSeason.id}`);
}
const game = await this.gameRepository.findById(activeSeason.gameId);
if (!game) {
throw new Error(`Game ${activeSeason.gameId} not found`);
}
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,
...(presetId !== undefined ? { scoringPresetId: presetId } : {}),
...(preset !== undefined ? { preset } : {}),
championships: scoringConfig.championships,
};
presenter.reset();
presenter.present(data);
}
}

View File

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

View File

@@ -0,0 +1,109 @@
/**
* Use Case for retrieving league statistics.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { IResultRepository } from '../../domain/repositories/IRaceRepository';
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
import type { ILeagueStatsPresenter } from '../presenters/ILeagueStatsPresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import { ILogger } from '../../../shared/src/logging/ILogger';
import {
AverageStrengthOfFieldCalculator,
type StrengthOfFieldCalculator,
} from '../../domain/services/StrengthOfFieldCalculator';
export interface GetLeagueStatsUseCaseParams {
leagueId: string;
}
/**
* Use Case for retrieving league statistics including average SOF across completed races.
*/
export class GetLeagueStatsUseCase
implements AsyncUseCase<GetLeagueStatsUseCaseParams, void> {
private readonly sofCalculator: StrengthOfFieldCalculator;
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly raceRepository: IRaceRepository,
private readonly resultRepository: IResultRepository,
private readonly driverRatingProvider: DriverRatingProvider,
public readonly presenter: ILeagueStatsPresenter,
private readonly logger: ILogger,
sofCalculator?: StrengthOfFieldCalculator,
) {
this.sofCalculator = sofCalculator ?? new AverageStrengthOfFieldCalculator();
}
async execute(params: GetLeagueStatsUseCaseParams): Promise<void> {
this.logger.debug(
`Executing GetLeagueStatsUseCase with params: ${JSON.stringify(params)}`,
);
const { leagueId } = params;
try {
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
this.logger.error(`League ${leagueId} not found`);
throw new Error(`League ${leagueId} not found`);
}
const races = await this.raceRepository.findByLeagueId(leagueId);
const completedRaces = races.filter(r => r.status === 'completed');
const scheduledRaces = races.filter(r => r.status === 'scheduled');
this.logger.info(
`Found ${races.length} races for league ${leagueId}: ${completedRaces.length} completed, ${scheduledRaces.length} scheduled. `,
);
// Calculate SOF for each completed race
const sofValues: number[] = [];
for (const race of completedRaces) {
// Use stored SOF if available
if (race.strengthOfField) {
this.logger.debug(
`Using stored Strength of Field for race ${race.id}: ${race.strengthOfField}`,
);
sofValues.push(race.strengthOfField);
continue;
}
// Otherwise calculate from results
const results = await this.resultRepository.findByRaceId(race.id);
if (results.length === 0) {
this.logger.debug(`No results found for race ${race.id}. Skipping SOF calculation.`);
continue;
}
const driverIds = results.map(r => r.driverId);
const ratings = this.driverRatingProvider.getRatings(driverIds);
const driverRatings = driverIds
.filter(id => ratings.has(id))
.map(id => ({ driverId: id, rating: ratings.get(id)! }));
const sof = this.sofCalculator.calculate(driverRatings);
if (sof !== null) {
this.logger.debug(`Calculated Strength of Field for race ${race.id}: ${sof}`);
sofValues.push(sof);
} else {
this.logger.warn(`Could not calculate Strength of Field for race ${race.id}`);
}
}
this.presenter.present(
leagueId,
races.length,
completedRaces.length,
scheduledRaces.length,
sofValues,
);
this.logger.info(`Successfully presented league statistics for league ${leagueId}.`);
} catch (error) {
this.logger.error(`Error in GetLeagueStatsUseCase: ${error.message}`);
throw error;
}
}
}

View File

@@ -0,0 +1,97 @@
/**
* Application Use Case: GetPendingSponsorshipRequestsUseCase
*
* Retrieves pending sponsorship requests for an entity owner to review.
*/
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
import type {
IPendingSponsorshipRequestsPresenter,
PendingSponsorshipRequestsViewModel,
} from '../presenters/IPendingSponsorshipRequestsPresenter';
export interface GetPendingSponsorshipRequestsDTO {
entityType: SponsorableEntityType;
entityId: string;
}
export interface PendingSponsorshipRequestDTO {
id: string;
sponsorId: string;
sponsorName: string;
sponsorLogo?: string;
tier: SponsorshipTier;
offeredAmount: number;
currency: string;
formattedAmount: string;
message?: string;
createdAt: Date;
platformFee: number;
netAmount: number;
}
export interface GetPendingSponsorshipRequestsResultDTO {
entityType: SponsorableEntityType;
entityId: string;
requests: PendingSponsorshipRequestDTO[];
totalCount: number;
}
export class GetPendingSponsorshipRequestsUseCase
implements UseCase<
GetPendingSponsorshipRequestsDTO,
GetPendingSponsorshipRequestsResultDTO,
PendingSponsorshipRequestsViewModel,
IPendingSponsorshipRequestsPresenter
> {
constructor(
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly sponsorRepo: ISponsorRepository,
) {}
async execute(
dto: GetPendingSponsorshipRequestsDTO,
presenter: IPendingSponsorshipRequestsPresenter,
): Promise<void> {
presenter.reset();
const requests = await this.sponsorshipRequestRepo.findPendingByEntity(
dto.entityType,
dto.entityId
);
const requestDTOs: PendingSponsorshipRequestDTO[] = [];
for (const request of requests) {
const sponsor = await this.sponsorRepo.findById(request.sponsorId);
requestDTOs.push({
id: request.id,
sponsorId: request.sponsorId,
sponsorName: sponsor?.name ?? 'Unknown Sponsor',
...(sponsor?.logoUrl !== undefined ? { sponsorLogo: sponsor.logoUrl } : {}),
tier: request.tier,
offeredAmount: request.offeredAmount.amount,
currency: request.offeredAmount.currency,
formattedAmount: request.offeredAmount.format(),
...(request.message !== undefined ? { message: request.message } : {}),
createdAt: request.createdAt,
platformFee: request.getPlatformFee().amount,
netAmount: request.getNetAmount().amount,
});
}
// Sort by creation date (newest first)
requestDTOs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
presenter.present({
entityType: dto.entityType,
entityId: dto.entityId,
requests: requestDTOs,
totalCount: requestDTOs.length,
});
}
}

View File

@@ -0,0 +1,451 @@
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { IImageServicePort } from '../ports/IImageServicePort';
import type { ISocialGraphRepository } from '@gridpilot/social/domain/repositories/ISocialGraphRepository';
import type {
IProfileOverviewPresenter,
ProfileOverviewViewModel,
ProfileOverviewDriverSummaryViewModel,
ProfileOverviewStatsViewModel,
ProfileOverviewFinishDistributionViewModel,
ProfileOverviewTeamMembershipViewModel,
ProfileOverviewSocialSummaryViewModel,
ProfileOverviewExtendedProfileViewModel,
} from '../presenters/IProfileOverviewPresenter';
interface ProfileDriverStatsAdapter {
rating: number | null;
wins: number;
podiums: number;
dnfs: number;
totalRaces: number;
avgFinish: number | null;
bestFinish: number | null;
worstFinish: number | null;
overallRank: number | null;
consistency: number | null;
percentile: number | null;
}
interface DriverRankingEntry {
driverId: string;
rating: number;
overallRank: number | null;
}
export interface GetProfileOverviewParams {
driverId: string;
}
export class GetProfileOverviewUseCase {
constructor(
private readonly driverRepository: IDriverRepository,
private readonly teamRepository: ITeamRepository,
private readonly teamMembershipRepository: ITeamMembershipRepository,
private readonly socialRepository: ISocialGraphRepository,
private readonly imageService: IImageServicePort,
private readonly getDriverStats: (driverId: string) => ProfileDriverStatsAdapter | null,
private readonly getAllDriverRankings: () => DriverRankingEntry[],
public readonly presenter: IProfileOverviewPresenter,
) {}
async execute(params: GetProfileOverviewParams): Promise<ProfileOverviewViewModel | null> {
const { driverId } = params;
const driver = await this.driverRepository.findById(driverId);
if (!driver) {
const emptyViewModel: ProfileOverviewViewModel = {
currentDriver: null,
stats: null,
finishDistribution: null,
teamMemberships: [],
socialSummary: {
friendsCount: 0,
friends: [],
},
extendedProfile: null,
};
this.presenter.present(emptyViewModel);
return emptyViewModel;
}
const [statsAdapter, teams, friends] = await Promise.all([
Promise.resolve(this.getDriverStats(driverId)),
this.teamRepository.findAll(),
this.socialRepository.getFriends(driverId),
]);
const driverSummary = this.buildDriverSummary(driver, statsAdapter);
const stats = this.buildStats(statsAdapter);
const finishDistribution = this.buildFinishDistribution(statsAdapter);
const teamMemberships = await this.buildTeamMemberships(driver.id, teams);
const socialSummary = this.buildSocialSummary(friends);
const extendedProfile = this.buildExtendedProfile(driver.id);
const viewModel: ProfileOverviewViewModel = {
currentDriver: driverSummary,
stats,
finishDistribution,
teamMemberships,
socialSummary,
extendedProfile,
};
this.presenter.present(viewModel);
return viewModel;
}
private buildDriverSummary(
driver: any,
stats: ProfileDriverStatsAdapter | null,
): ProfileOverviewDriverSummaryViewModel {
const rankings = this.getAllDriverRankings();
const fallbackRank = this.computeFallbackRank(driver.id, rankings);
const totalDrivers = rankings.length;
return {
id: driver.id,
name: driver.name,
country: driver.country,
avatarUrl: this.imageService.getDriverAvatar(driver.id),
iracingId: driver.iracingId ?? null,
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,
};
}
private computeFallbackRank(
driverId: string,
rankings: DriverRankingEntry[],
): number | null {
const index = rankings.findIndex(entry => entry.driverId === driverId);
if (index === -1) {
return null;
}
return index + 1;
}
private buildStats(
stats: ProfileDriverStatsAdapter | null,
): ProfileOverviewStatsViewModel | null {
if (!stats) {
return null;
}
const totalRaces = stats.totalRaces;
const dnfs = stats.dnfs;
const finishedRaces = Math.max(totalRaces - dnfs, 0);
const finishRate =
totalRaces > 0 ? (finishedRaces / totalRaces) * 100 : null;
const winRate =
totalRaces > 0 ? (stats.wins / totalRaces) * 100 : null;
const podiumRate =
totalRaces > 0 ? (stats.podiums / totalRaces) * 100 : null;
return {
totalRaces,
wins: stats.wins,
podiums: stats.podiums,
dnfs,
avgFinish: stats.avgFinish,
bestFinish: stats.bestFinish,
worstFinish: stats.worstFinish,
finishRate,
winRate,
podiumRate,
percentile: stats.percentile,
rating: stats.rating,
consistency: stats.consistency,
overallRank: stats.overallRank,
};
}
private buildFinishDistribution(
stats: ProfileDriverStatsAdapter | null,
): ProfileOverviewFinishDistributionViewModel | null {
if (!stats || stats.totalRaces <= 0) {
return null;
}
const totalRaces = stats.totalRaces;
const dnfs = stats.dnfs;
const finishedRaces = Math.max(totalRaces - dnfs, 0);
const estimatedTopTen = Math.min(
finishedRaces,
Math.round(totalRaces * 0.7),
);
const topTen = Math.max(estimatedTopTen, stats.podiums);
const other = Math.max(totalRaces - topTen, 0);
return {
totalRaces,
wins: stats.wins,
podiums: stats.podiums,
topTen,
dnfs,
other,
};
}
private async buildTeamMemberships(
driverId: string,
teams: any[],
): Promise<ProfileOverviewTeamMembershipViewModel[]> {
const memberships: ProfileOverviewTeamMembershipViewModel[] = [];
for (const team of teams) {
const membership = await this.teamMembershipRepository.getMembership(
team.id,
driverId,
);
if (!membership) continue;
memberships.push({
teamId: team.id,
teamName: team.name,
teamTag: team.tag ?? null,
role: membership.role,
joinedAt:
membership.joinedAt instanceof Date
? membership.joinedAt.toISOString()
: new Date(membership.joinedAt).toISOString(),
isCurrent: membership.status === 'active',
});
}
memberships.sort((a, b) => a.joinedAt.localeCompare(b.joinedAt));
return memberships;
}
private buildSocialSummary(friends: any[]): ProfileOverviewSocialSummaryViewModel {
return {
friendsCount: friends.length,
friends: friends.map(friend => ({
id: friend.id,
name: friend.name,
country: friend.country,
avatarUrl: this.imageService.getDriverAvatar(friend.id),
})),
};
}
private buildExtendedProfile(driverId: string): ProfileOverviewExtendedProfileViewModel {
const hash = driverId
.split('')
.reduce((acc: number, char: string) => acc + char.charCodeAt(0), 0);
const socialOptions: Array<
Array<{
platform: 'twitter' | 'youtube' | 'twitch' | 'discord';
handle: string;
url: string;
}>
> = [
[
{
platform: 'twitter',
handle: '@speedracer',
url: 'https://twitter.com/speedracer',
},
{
platform: 'youtube',
handle: 'SpeedRacer Racing',
url: 'https://youtube.com/@speedracer',
},
{
platform: 'twitch',
handle: 'speedracer_live',
url: 'https://twitch.tv/speedracer_live',
},
],
[
{
platform: 'twitter',
handle: '@racingpro',
url: 'https://twitter.com/racingpro',
},
{
platform: 'discord',
handle: 'RacingPro#1234',
url: '#',
},
],
[
{
platform: 'twitch',
handle: 'simracer_elite',
url: 'https://twitch.tv/simracer_elite',
},
{
platform: 'youtube',
handle: 'SimRacer Elite',
url: 'https://youtube.com/@simracerelite',
},
],
];
const achievementSets: Array<
Array<{
id: string;
title: string;
description: string;
icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap';
rarity: 'common' | 'rare' | 'epic' | 'legendary';
earnedAt: Date;
}>
> = [
[
{
id: '1',
title: 'First Victory',
description: 'Win your first race',
icon: 'trophy',
rarity: 'common',
earnedAt: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000),
},
{
id: '2',
title: 'Clean Racer',
description: '10 races without incidents',
icon: 'star',
rarity: 'rare',
earnedAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
},
{
id: '3',
title: 'Podium Streak',
description: '5 consecutive podium finishes',
icon: 'medal',
rarity: 'epic',
earnedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
},
{
id: '4',
title: 'Championship Glory',
description: 'Win a league championship',
icon: 'crown',
rarity: 'legendary',
earnedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
},
],
[
{
id: '1',
title: 'Rookie No More',
description: 'Complete 25 races',
icon: 'target',
rarity: 'common',
earnedAt: new Date(Date.now() - 120 * 24 * 60 * 60 * 1000),
},
{
id: '2',
title: 'Consistent Performer',
description: 'Maintain 80%+ consistency rating',
icon: 'zap',
rarity: 'rare',
earnedAt: new Date(Date.now() - 45 * 24 * 60 * 60 * 1000),
},
{
id: '3',
title: 'Endurance Master',
description: 'Complete a 24-hour race',
icon: 'star',
rarity: 'epic',
earnedAt: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000),
},
],
[
{
id: '1',
title: 'Welcome Racer',
description: 'Join GridPilot',
icon: 'star',
rarity: 'common',
earnedAt: new Date(Date.now() - 180 * 24 * 60 * 60 * 1000),
},
{
id: '2',
title: 'Team Player',
description: 'Join a racing team',
icon: 'medal',
rarity: 'rare',
earnedAt: new Date(Date.now() - 80 * 24 * 60 * 60 * 1000),
},
],
];
const tracks = [
'Spa-Francorchamps',
'Nürburgring Nordschleife',
'Suzuka',
'Monza',
'Interlagos',
'Silverstone',
];
const cars = [
'Porsche 911 GT3 R',
'Ferrari 488 GT3',
'Mercedes-AMG GT3',
'BMW M4 GT3',
'Audi R8 LMS',
];
const styles = [
'Aggressive Overtaker',
'Consistent Pacer',
'Strategic Calculator',
'Late Braker',
'Smooth Operator',
];
const timezones = [
'EST (UTC-5)',
'CET (UTC+1)',
'PST (UTC-8)',
'GMT (UTC+0)',
'JST (UTC+9)',
];
const hours = [
'Evenings (18:00-23:00)',
'Weekends only',
'Late nights (22:00-02:00)',
'Flexible schedule',
];
const socialHandles =
socialOptions[hash % socialOptions.length] ?? [];
const achievementsSource =
achievementSets[hash % achievementSets.length] ?? [];
return {
socialHandles,
achievements: achievementsSource.map(achievement => ({
id: achievement.id,
title: achievement.title,
description: achievement.description,
icon: achievement.icon,
rarity: achievement.rarity,
earnedAt: achievement.earnedAt.toISOString(),
})),
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

@@ -0,0 +1,167 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
import type { IImageServicePort } from '../ports/IImageServicePort';
import type {
IRaceDetailPresenter,
RaceDetailViewModel,
RaceDetailRaceViewModel,
RaceDetailLeagueViewModel,
RaceDetailEntryViewModel,
RaceDetailUserResultViewModel,
} from '../presenters/IRaceDetailPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
/**
* Use Case: GetRaceDetailUseCase
*
* Given a race id and current driver id:
* - When the race exists, it builds a view model with race, league, entry list, registration flags and user result.
* - When the race does not exist, it presents a view model with an error and no race data.
*
* Given a completed race with a result for the driver:
* - When computing rating change, it applies the same position-based formula used in the legacy UI.
*/
export interface GetRaceDetailQueryParams {
raceId: string;
driverId: string;
}
export class GetRaceDetailUseCase
implements UseCase<GetRaceDetailQueryParams, RaceDetailViewModel, RaceDetailViewModel, IRaceDetailPresenter>
{
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly driverRepository: IDriverRepository,
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
private readonly resultRepository: IResultRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly driverRatingProvider: DriverRatingProvider,
private readonly imageService: IImageServicePort,
) {}
async execute(params: GetRaceDetailQueryParams, presenter: IRaceDetailPresenter): Promise<void> {
presenter.reset();
const { raceId, driverId } = params;
const race = await this.raceRepository.findById(raceId);
if (!race) {
const emptyViewModel: RaceDetailViewModel = {
race: null,
league: null,
entryList: [],
registration: {
isUserRegistered: false,
canRegister: false,
},
userResult: null,
error: 'Race not found',
};
presenter.present(emptyViewModel);
return;
}
const [league, registeredDriverIds, membership] = await Promise.all([
this.leagueRepository.findById(race.leagueId),
this.raceRegistrationRepository.getRegisteredDrivers(race.id),
this.leagueMembershipRepository.getMembership(race.leagueId, driverId),
]);
const ratings = this.driverRatingProvider.getRatings(registeredDriverIds);
const drivers = await Promise.all(
registeredDriverIds.map(id => this.driverRepository.findById(id)),
);
const entryList: RaceDetailEntryViewModel[] = drivers
.filter((d): d is NonNullable<typeof d> => d !== null)
.map(driver => ({
id: driver.id,
name: driver.name,
country: driver.country,
avatarUrl: this.imageService.getDriverAvatar(driver.id),
rating: ratings.get(driver.id) ?? null,
isCurrentUser: driver.id === driverId,
}));
const isUserRegistered = registeredDriverIds.includes(driverId);
const isUpcoming = race.status === 'scheduled' && race.scheduledAt > new Date();
const canRegister = !!membership && membership.status === 'active' && isUpcoming;
let userResultView: RaceDetailUserResultViewModel | null = null;
if (race.status === 'completed') {
const results = await this.resultRepository.findByRaceId(race.id);
const userResult = results.find(r => r.driverId === driverId) ?? null;
if (userResult) {
const ratingChange = this.calculateRatingChange(userResult.position);
userResultView = {
position: userResult.position,
startPosition: userResult.startPosition,
incidents: userResult.incidents,
fastestLap: userResult.fastestLap,
positionChange: userResult.getPositionChange(),
isPodium: userResult.isPodium(),
isClean: userResult.isClean(),
ratingChange,
};
}
}
const raceView: RaceDetailRaceViewModel = {
id: race.id,
leagueId: race.leagueId,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt.toISOString(),
sessionType: race.sessionType,
status: race.status,
strengthOfField: race.strengthOfField ?? null,
...(race.registeredCount !== undefined ? { registeredCount: race.registeredCount } : {}),
...(race.maxParticipants !== undefined ? { maxParticipants: race.maxParticipants } : {}),
};
const leagueView: RaceDetailLeagueViewModel | null = league
? {
id: league.id,
name: league.name,
description: league.description,
settings: {
...(league.settings.maxDrivers !== undefined
? { maxDrivers: league.settings.maxDrivers }
: {}),
...(league.settings.qualifyingFormat !== undefined
? { qualifyingFormat: league.settings.qualifyingFormat }
: {}),
},
}
: null;
const viewModel: RaceDetailViewModel = {
race: raceView,
league: leagueView,
entryList,
registration: {
isUserRegistered,
canRegister,
},
userResult: userResultView,
};
presenter.present(viewModel);
}
private calculateRatingChange(position: number): number {
const baseChange = position <= 3 ? 25 : position <= 10 ? 10 : -5;
const positionBonus = Math.max(0, (20 - position) * 2);
return baseChange + positionBonus;
}
}

View File

@@ -0,0 +1,57 @@
/**
* Use Case: GetRacePenaltiesUseCase
*
* Returns all penalties applied for a specific race, with driver details.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type {
IRacePenaltiesPresenter,
RacePenaltiesResultDTO,
RacePenaltiesViewModel,
} from '../presenters/IRacePenaltiesPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
export interface GetRacePenaltiesInput {
raceId: string;
}
export class GetRacePenaltiesUseCase
implements
UseCase<GetRacePenaltiesInput, RacePenaltiesResultDTO, RacePenaltiesViewModel, IRacePenaltiesPresenter>
{
constructor(
private readonly penaltyRepository: IPenaltyRepository,
private readonly driverRepository: IDriverRepository,
) {}
async execute(input: GetRacePenaltiesInput, presenter: IRacePenaltiesPresenter): Promise<void> {
const penalties = await this.penaltyRepository.findByRaceId(input.raceId);
const driverIds = new Set<string>();
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)),
);
const driverMap = new Map<string, string>();
drivers.forEach((driver) => {
if (driver) {
driverMap.set(driver.id, driver.name);
}
});
presenter.reset();
const dto: RacePenaltiesResultDTO = {
penalties,
driverMap,
};
presenter.present(dto);
}
}

View File

@@ -0,0 +1,60 @@
/**
* Use Case: GetRaceProtestsUseCase
*
* Returns all protests filed for a specific race, with driver details.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type {
IRaceProtestsPresenter,
RaceProtestsResultDTO,
RaceProtestsViewModel,
} from '../presenters/IRaceProtestsPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
export interface GetRaceProtestsInput {
raceId: string;
}
export class GetRaceProtestsUseCase
implements
UseCase<GetRaceProtestsInput, RaceProtestsResultDTO, RaceProtestsViewModel, IRaceProtestsPresenter>
{
constructor(
private readonly protestRepository: IProtestRepository,
private readonly driverRepository: IDriverRepository,
) {}
async execute(input: GetRaceProtestsInput, presenter: IRaceProtestsPresenter): Promise<void> {
const protests = await this.protestRepository.findByRaceId(input.raceId);
const driverIds = new Set<string>();
protests.forEach((protest) => {
driverIds.add(protest.protestingDriverId);
driverIds.add(protest.accusedDriverId);
if (protest.reviewedBy) {
driverIds.add(protest.reviewedBy);
}
});
const drivers = await Promise.all(
Array.from(driverIds).map((id) => this.driverRepository.findById(id)),
);
const driverMap = new Map<string, string>();
drivers.forEach((driver) => {
if (driver) {
driverMap.set(driver.id, driver.name);
}
});
presenter.reset();
const dto: RaceProtestsResultDTO = {
protests,
driverMap,
};
presenter.present(dto);
}
}

View File

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

View File

@@ -0,0 +1,161 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type {
IRaceResultsDetailPresenter,
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';
import type { Penalty } from '../../domain/entities/Penalty';
export interface GetRaceResultsDetailParams {
raceId: string;
driverId?: string;
}
function buildPointsSystem(league: League | null): Record<number, number> | undefined {
if (!league) return undefined;
const pointsSystems: Record<string, Record<number, number>> = {
'f1-2024': {
1: 25,
2: 18,
3: 15,
4: 12,
5: 10,
6: 8,
7: 6,
8: 4,
9: 2,
10: 1,
},
indycar: {
1: 50,
2: 40,
3: 35,
4: 32,
5: 30,
6: 28,
7: 26,
8: 24,
9: 22,
10: 20,
11: 19,
12: 18,
13: 17,
14: 16,
15: 15,
},
};
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 {
if (results.length === 0) return undefined;
return Math.min(...results.map((r) => r.fastestLap));
}
function mapPenaltySummary(penalties: Penalty[]): RaceResultsPenaltySummaryViewModel[] {
return penalties.map((p) => ({
driverId: p.driverId,
type: p.type,
...(p.value !== undefined ? { value: p.value } : {}),
}));
}
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,
) {}
async execute(
params: GetRaceResultsDetailParams,
presenter: IRaceResultsDetailPresenter,
): Promise<void> {
presenter.reset();
const { raceId, driverId } = params;
const race = await this.raceRepository.findById(raceId);
if (!race) {
const errorViewModel: RaceResultsDetailViewModel = {
race: null,
league: null,
results: [],
drivers: [],
penalties: [],
...(driverId ? { currentDriverId: driverId } : {}),
error: 'Race not found',
};
presenter.present(errorViewModel);
return;
}
const [league, results, drivers, penalties] = await Promise.all([
this.leagueRepository.findById(race.leagueId),
this.resultRepository.findByRaceId(raceId),
this.driverRepository.findAll(),
this.penaltyRepository.findByRaceId(raceId),
]);
const effectiveCurrentDriverId =
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,
leagueId: race.leagueId,
track: race.track,
scheduledAt: race.scheduledAt,
status: race.status,
},
league: league
? {
id: league.id,
name: league.name,
}
: null,
results,
drivers,
penalties: penaltySummary,
...(pointsSystem ? { pointsSystem } : {}),
...(fastestLapTime !== undefined ? { fastestLapTime } : {}),
...(effectiveCurrentDriverId ? { currentDriverId: effectiveCurrentDriverId } : {}),
};
presenter.present(viewModel);
}
}

View File

@@ -0,0 +1,93 @@
/**
* Use Case: GetRaceWithSOFUseCase
*
* Returns race details enriched with calculated Strength of Field (SOF).
* SOF is calculated from participant ratings if not already stored on the race.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
import {
AverageStrengthOfFieldCalculator,
type StrengthOfFieldCalculator,
} from '../../domain/services/StrengthOfFieldCalculator';
import type { IRaceWithSOFPresenter, RaceWithSOFResultDTO } from '../presenters/IRaceWithSOFPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
export interface GetRaceWithSOFQueryParams {
raceId: string;
}
export class GetRaceWithSOFUseCase
implements UseCase<GetRaceWithSOFQueryParams, RaceWithSOFResultDTO, import('../presenters/IRaceWithSOFPresenter').RaceWithSOFViewModel, IRaceWithSOFPresenter>
{
private readonly sofCalculator: StrengthOfFieldCalculator;
constructor(
private readonly raceRepository: IRaceRepository,
private readonly registrationRepository: IRaceRegistrationRepository,
private readonly resultRepository: IResultRepository,
private readonly driverRatingProvider: DriverRatingProvider,
sofCalculator?: StrengthOfFieldCalculator,
) {
this.sofCalculator = sofCalculator ?? new AverageStrengthOfFieldCalculator();
}
async execute(params: GetRaceWithSOFQueryParams, presenter: IRaceWithSOFPresenter): Promise<void> {
presenter.reset();
const { raceId } = params;
const race = await this.raceRepository.findById(raceId);
if (!race) {
return;
}
// Get participant IDs based on race status
let participantIds: string[] = [];
if (race.status === 'completed') {
// For completed races, use results
const results = await this.resultRepository.findByRaceId(raceId);
participantIds = results.map(r => r.driverId);
} else {
// For upcoming/running races, use registrations
participantIds = await this.registrationRepository.getRegisteredDrivers(raceId);
}
// 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);
}
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,
registeredCount: race.registeredCount ?? participantIds.length,
maxParticipants: race.maxParticipants ?? participantIds.length,
participantCount: participantIds.length,
};
presenter.present(dto);
}
}

View File

@@ -0,0 +1,50 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type {
IRacesPagePresenter,
RacesPageResultDTO,
RacesPageViewModel,
} from '@gridpilot/racing/application/presenters/IRacesPagePresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
export class GetRacesPageDataUseCase
implements UseCase<void, RacesPageResultDTO, RacesPageViewModel, IRacesPagePresenter>
{
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
) {}
async execute(_input: void, presenter: IRacesPagePresenter): Promise<void> {
presenter.reset();
const [allRaces, allLeagues] = await Promise.all([
this.raceRepository.findAll(),
this.leagueRepository.findAll(),
]);
const leagueMap = new Map(allLeagues.map(l => [l.id, l.name]));
const races = allRaces
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime())
.map(race => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt.toISOString(),
status: race.status,
leagueId: race.leagueId,
leagueName: leagueMap.get(race.leagueId) ?? 'Unknown League',
strengthOfField: race.strengthOfField,
isUpcoming: race.isUpcoming(),
isLive: race.isLive(),
isPast: race.isPast(),
}));
const dto: RacesPageResultDTO = {
races,
};
presenter.present(dto);
}
}

View File

@@ -0,0 +1,179 @@
/**
* Application Use Case: GetSponsorDashboardUseCase
*
* Returns sponsor dashboard metrics including sponsorships, impressions, and investment data.
*/
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type {
ISponsorDashboardPresenter,
SponsorDashboardViewModel,
} from '../presenters/ISponsorDashboardPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
export interface GetSponsorDashboardQueryParams {
sponsorId: string;
}
export interface SponsoredLeagueDTO {
id: string;
name: string;
tier: 'main' | 'secondary';
drivers: number;
races: number;
impressions: number;
status: 'active' | 'upcoming' | 'completed';
}
export interface SponsorDashboardDTO {
sponsorId: string;
sponsorName: string;
metrics: {
impressions: number;
impressionsChange: number;
uniqueViewers: number;
viewersChange: number;
races: number;
drivers: number;
exposure: number;
exposureChange: number;
};
sponsoredLeagues: SponsoredLeagueDTO[];
investment: {
activeSponsorships: number;
totalInvestment: number;
costPerThousandViews: number;
};
}
export class GetSponsorDashboardUseCase
implements UseCase<GetSponsorDashboardQueryParams, SponsorDashboardDTO | null, SponsorDashboardViewModel, ISponsorDashboardPresenter>
{
constructor(
private readonly sponsorRepository: ISponsorRepository,
private readonly seasonSponsorshipRepository: ISeasonSponsorshipRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly raceRepository: IRaceRepository,
) {}
async execute(
params: GetSponsorDashboardQueryParams,
presenter: ISponsorDashboardPresenter,
): Promise<void> {
presenter.reset();
const { sponsorId } = params;
const sponsor = await this.sponsorRepository.findById(sponsorId);
if (!sponsor) {
presenter.present(null);
return;
}
// Get all sponsorships for this sponsor
const sponsorships = await this.seasonSponsorshipRepository.findBySponsorId(sponsorId);
// Aggregate data across all sponsorships
let totalImpressions = 0;
let totalDrivers = 0;
let totalRaces = 0;
let totalInvestment = 0;
const sponsoredLeagues: SponsoredLeagueDTO[] = [];
const seenLeagues = new Set<string>();
for (const sponsorship of sponsorships) {
// Get season to find league
const season = await this.seasonRepository.findById(sponsorship.seasonId);
if (!season) continue;
// Only process each league once
if (seenLeagues.has(season.leagueId)) continue;
seenLeagues.add(season.leagueId);
const league = await this.leagueRepository.findById(season.leagueId);
if (!league) continue;
// Get membership count for this league
const memberships = await this.leagueMembershipRepository.getLeagueMembers(season.leagueId);
const driverCount = memberships.length;
totalDrivers += driverCount;
// Get races for this league
const races = await this.raceRepository.findByLeagueId(season.leagueId);
const raceCount = races.length;
totalRaces += raceCount;
// Calculate impressions based on completed races and drivers
// This is a simplified calculation - in production would come from analytics
const completedRaces = races.filter(r => r.status === 'completed').length;
const leagueImpressions = completedRaces * driverCount * 100; // Simplified: 100 views per driver per race
totalImpressions += leagueImpressions;
// Determine status based on season dates
const now = new Date();
let status: 'active' | 'upcoming' | 'completed' = 'active';
if (season.endDate && season.endDate < now) {
status = 'completed';
} else if (season.startDate && season.startDate > now) {
status = 'upcoming';
}
// Add investment
totalInvestment += sponsorship.pricing.amount;
sponsoredLeagues.push({
id: league.id,
name: league.name,
tier: sponsorship.tier,
drivers: driverCount,
races: raceCount,
impressions: leagueImpressions,
status,
});
}
const activeSponsorships = sponsorships.filter(s => s.status === 'active').length;
const costPerThousandViews = totalImpressions > 0
? (totalInvestment / (totalImpressions / 1000))
: 0;
// Calculate unique viewers (simplified: assume 70% of impressions are unique)
const uniqueViewers = Math.round(totalImpressions * 0.7);
// Calculate exposure score (0-100 based on tier distribution)
const mainSponsorships = sponsorships.filter(s => s.tier === 'main').length;
const exposure = sponsorships.length > 0
? Math.min(100, (mainSponsorships * 30) + (sponsorships.length * 10))
: 0;
const dto: SponsorDashboardDTO = {
sponsorId,
sponsorName: sponsor.name,
metrics: {
impressions: totalImpressions,
impressionsChange: 12.5, // Would come from analytics comparison
uniqueViewers,
viewersChange: 8.3, // Would come from analytics comparison
races: totalRaces,
drivers: totalDrivers,
exposure,
exposureChange: 5.2, // Would come from analytics comparison
},
sponsoredLeagues,
investment: {
activeSponsorships,
totalInvestment,
costPerThousandViews: Math.round(costPerThousandViews * 100) / 100,
},
};
presenter.present(dto);
}
}

View File

@@ -0,0 +1,178 @@
/**
* Application Use Case: GetSponsorSponsorshipsUseCase
*
* Returns detailed sponsorship information for a sponsor's campaigns/sponsorships page.
*/
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
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,
SponsorSponsorshipsViewModel,
} from '../presenters/ISponsorSponsorshipsPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
export interface GetSponsorSponsorshipsQueryParams {
sponsorId: string;
}
export interface SponsorshipDetailDTO {
id: string;
leagueId: string;
leagueName: string;
seasonId: string;
seasonName: string;
seasonStartDate?: Date;
seasonEndDate?: Date;
tier: SponsorshipTier;
status: SponsorshipStatus;
pricing: {
amount: number;
currency: string;
};
platformFee: {
amount: number;
currency: string;
};
netAmount: {
amount: number;
currency: string;
};
metrics: {
drivers: number;
races: number;
completedRaces: number;
impressions: number;
};
createdAt: Date;
activatedAt?: Date;
}
export interface SponsorSponsorshipsDTO {
sponsorId: string;
sponsorName: string;
sponsorships: SponsorshipDetailDTO[];
summary: {
totalSponsorships: number;
activeSponsorships: number;
totalInvestment: number;
totalPlatformFees: number;
currency: string;
};
}
export class GetSponsorSponsorshipsUseCase
implements UseCase<GetSponsorSponsorshipsQueryParams, SponsorSponsorshipsDTO | null, SponsorSponsorshipsViewModel, ISponsorSponsorshipsPresenter>
{
constructor(
private readonly sponsorRepository: ISponsorRepository,
private readonly seasonSponsorshipRepository: ISeasonSponsorshipRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly raceRepository: IRaceRepository,
) {}
async execute(
params: GetSponsorSponsorshipsQueryParams,
presenter: ISponsorSponsorshipsPresenter,
): Promise<void> {
presenter.reset();
const { sponsorId } = params;
const sponsor = await this.sponsorRepository.findById(sponsorId);
if (!sponsor) {
presenter.present(null);
return;
}
// Get all sponsorships for this sponsor
const sponsorships = await this.seasonSponsorshipRepository.findBySponsorId(sponsorId);
const sponsorshipDetails: SponsorshipDetailDTO[] = [];
let totalInvestment = 0;
let totalPlatformFees = 0;
for (const sponsorship of sponsorships) {
// Get season to find league
const season = await this.seasonRepository.findById(sponsorship.seasonId);
if (!season) continue;
const league = await this.leagueRepository.findById(season.leagueId);
if (!league) continue;
// Get membership count for this league
const memberships = await this.leagueMembershipRepository.getLeagueMembers(season.leagueId);
const driverCount = memberships.length;
// Get races for this league
const races = await this.raceRepository.findByLeagueId(season.leagueId);
const completedRaces = races.filter(r => r.status === 'completed').length;
// Calculate impressions
const impressions = completedRaces * driverCount * 100;
// Calculate platform fee (10%)
const platformFee = sponsorship.getPlatformFee();
const netAmount = sponsorship.getNetAmount();
totalInvestment += sponsorship.pricing.amount;
totalPlatformFees += platformFee.amount;
sponsorshipDetails.push({
id: sponsorship.id,
leagueId: league.id,
leagueName: league.name,
seasonId: season.id,
seasonName: season.name,
...(season.startDate !== undefined ? { seasonStartDate: season.startDate } : {}),
...(season.endDate !== undefined ? { seasonEndDate: season.endDate } : {}),
tier: sponsorship.tier,
status: sponsorship.status,
pricing: {
amount: sponsorship.pricing.amount,
currency: sponsorship.pricing.currency,
},
platformFee: {
amount: platformFee.amount,
currency: platformFee.currency,
},
netAmount: {
amount: netAmount.amount,
currency: netAmount.currency,
},
metrics: {
drivers: driverCount,
races: races.length,
completedRaces,
impressions,
},
createdAt: sponsorship.createdAt,
...(sponsorship.activatedAt !== undefined ? { activatedAt: sponsorship.activatedAt } : {}),
});
}
const activeSponsorships = sponsorships.filter(s => s.status === 'active').length;
const dto: SponsorSponsorshipsDTO = {
sponsorId,
sponsorName: sponsor.name,
sponsorships: sponsorshipDetails,
summary: {
totalSponsorships: sponsorships.length,
activeSponsorships,
totalInvestment,
totalPlatformFees,
currency: 'USD',
},
};
presenter.present(dto);
}
}

View File

@@ -0,0 +1,44 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
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
implements UseCase<{ teamId: string; driverId: string }, TeamDetailsResultDTO, TeamDetailsViewModel, ITeamDetailsPresenter>
{
constructor(
private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository,
) {}
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');
}
const membership = await this.membershipRepository.getMembership(teamId, driverId);
const dto: TeamDetailsResultDTO = {
team,
membership,
driverId,
};
presenter.present(dto);
}
}

View File

@@ -0,0 +1,63 @@
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IImageServicePort } from '../ports/IImageServicePort';
import type {
ITeamJoinRequestsPresenter,
TeamJoinRequestsResultDTO,
TeamJoinRequestsViewModel,
} from '../presenters/ITeamJoinRequestsPresenter';
import type { UseCase } from '@gridpilot/shared/application';
import type { ILogger } from '../../../shared/src/logging/ILogger';
/**
* Use Case for retrieving team join requests.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetTeamJoinRequestsUseCase
implements UseCase<{ teamId: string }, TeamJoinRequestsResultDTO, TeamJoinRequestsViewModel, ITeamJoinRequestsPresenter>
{
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
private readonly driverRepository: IDriverRepository,
private readonly imageService: IImageServicePort,
private readonly logger: ILogger,
// 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(input: { teamId: string }, presenter: ITeamJoinRequestsPresenter): Promise<void> {
this.logger.debug('Executing GetTeamJoinRequestsUseCase', { teamId: input.teamId });
presenter.reset();
try {
const requests = await this.membershipRepository.getJoinRequests(input.teamId);
this.logger.info('Successfully retrieved team join requests', { teamId: input.teamId, count: requests.length });
const driverNames: Record<string, string> = {};
const avatarUrls: Record<string, string> = {};
for (const request of requests) {
const driver = await this.driverRepository.findById(request.driverId);
if (driver) {
driverNames[request.driverId] = driver.name;
} else {
this.logger.warn(`Driver not found for ID: ${request.driverId} during join request processing.`);
}
avatarUrls[request.driverId] = this.imageService.getDriverAvatar(request.driverId);
this.logger.debug('Processed driver details for join request', { driverId: request.driverId });
}
const dto: TeamJoinRequestsResultDTO = {
requests,
driverNames,
avatarUrls,
};
presenter.present(dto);
} catch (error) {
this.logger.error('Error retrieving team join requests', { teamId: input.teamId, error });
throw error;
}
}
}

View File

@@ -0,0 +1,64 @@
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IImageServicePort } from '../ports/IImageServicePort';
import type {
ITeamMembersPresenter,
TeamMembersResultDTO,
TeamMembersViewModel,
} from '../presenters/ITeamMembersPresenter';
import type { UseCase } from '@gridpilot/shared/application';
import type { ILogger } from '../../../shared/src/logging/ILogger';
/**
* Use Case for retrieving team members.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetTeamMembersUseCase
implements UseCase<{ teamId: string }, TeamMembersResultDTO, TeamMembersViewModel, ITeamMembersPresenter>
{
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
private readonly driverRepository: IDriverRepository,
private readonly imageService: IImageServicePort,
private readonly logger: ILogger,
// 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(input: { teamId: string }, presenter: ITeamMembersPresenter): Promise<void> {
this.logger.debug(`Executing GetTeamMembersUseCase for teamId: ${input.teamId}`);
presenter.reset();
try {
const memberships = await this.membershipRepository.getTeamMembers(input.teamId);
this.logger.info(`Found ${memberships.length} memberships for teamId: ${input.teamId}`);
const driverNames: Record<string, string> = {};
const avatarUrls: Record<string, string> = {};
for (const membership of memberships) {
this.logger.debug(`Processing membership for driverId: ${membership.driverId}`);
const driver = await this.driverRepository.findById(membership.driverId);
if (driver) {
driverNames[membership.driverId] = driver.name;
} else {
this.logger.warn(`Driver with ID ${membership.driverId} not found while fetching team members for team ${input.teamId}.`);
}
avatarUrls[membership.driverId] = this.imageService.getDriverAvatar(membership.driverId);
}
const dto: TeamMembersResultDTO = {
memberships,
driverNames,
avatarUrls,
};
presenter.present(dto);
this.logger.info(`Successfully presented team members for teamId: ${input.teamId}`);
} catch (error) {
this.logger.error(`Error in GetTeamMembersUseCase for teamId: ${input.teamId}, error: ${error instanceof Error ? error.message : String(error)}`);
throw error;
}
}
}

View File

@@ -0,0 +1,88 @@
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,
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;
wins: number;
totalRaces: number;
}
/**
* Use case: GetTeamsLeaderboardUseCase
*
* Plain constructor-injected dependencies (no decorators) to keep the
* application layer framework-agnostic and compatible with test tooling.
*/
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,
) {}
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.getTeamMembers(team.id);
const memberCount = memberships.length;
let ratingSum = 0;
let ratingCount = 0;
let totalWins = 0;
let totalRaces = 0;
for (const membership of memberships) {
const stats = this.getDriverStats(membership.driverId);
if (!stats) continue;
if (typeof stats.rating === 'number') {
ratingSum += stats.rating;
ratingCount += 1;
}
totalWins += stats.wins ?? 0;
totalRaces += stats.totalRaces ?? 0;
}
const averageRating = ratingCount > 0 ? ratingSum / ratingCount : null;
const performanceLevel = SkillLevelService.getTeamPerformanceLevel(averageRating);
teams.push({
id: team.id,
name: team.name,
memberCount,
rating: averageRating,
totalWins,
totalRaces,
performanceLevel,
isRecruiting: true,
createdAt: new Date(),
description: team.description,
});
})
);
const recruitingCount = teams.filter((t) => t.isRecruiting).length;
const result: TeamsLeaderboardResultDTO = {
teams,
recruitingCount,
};
presenter.reset();
presenter.present(result);
}
}

View File

@@ -0,0 +1,111 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import { Result } from '../../domain/entities/Result';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import {
BusinessRuleViolationError,
EntityNotFoundError,
} from '../errors/RacingApplicationError';
import type {
IImportRaceResultsPresenter,
ImportRaceResultsSummaryViewModel,
} from '../presenters/IImportRaceResultsPresenter';
import type { ILogger } from '../../../shared/src/logging/ILogger';
export interface ImportRaceResultDTO {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
}
export interface ImportRaceResultsParams {
raceId: string;
results: ImportRaceResultDTO[];
}
export class ImportRaceResultsUseCase
implements AsyncUseCase<ImportRaceResultsParams, void>
{
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly resultRepository: IResultRepository,
private readonly driverRepository: IDriverRepository,
private readonly standingRepository: IStandingRepository,
public readonly presenter: IImportRaceResultsPresenter,
private readonly logger: ILogger,
) {}
async execute(params: ImportRaceResultsParams): Promise<void> {
this.logger.debug('ImportRaceResultsUseCase:execute', { params });
const { raceId, results } = params;
try {
const race = await this.raceRepository.findById(raceId);
if (!race) {
this.logger.warn(`ImportRaceResultsUseCase: Race with ID ${raceId} not found.`);
throw new EntityNotFoundError({ entity: 'race', id: raceId });
}
this.logger.debug(`ImportRaceResultsUseCase: Race ${raceId} found.`);
const league = await this.leagueRepository.findById(race.leagueId);
if (!league) {
this.logger.warn(`ImportRaceResultsUseCase: League with ID ${race.leagueId} not found for race ${raceId}.`);
throw new EntityNotFoundError({ entity: 'league', id: race.leagueId });
}
this.logger.debug(`ImportRaceResultsUseCase: League ${league.id} found.`);
const existing = await this.resultRepository.existsByRaceId(raceId);
if (existing) {
this.logger.warn(`ImportRaceResultsUseCase: Results already exist for race ID: ${raceId}.`);
throw new BusinessRuleViolationError('Results already exist for this race');
}
this.logger.debug(`ImportRaceResultsUseCase: No existing results for race ${raceId}.`);
// Lookup drivers by iracingId and create results with driver.id
const entities = await Promise.all(
results.map(async (dto) => {
const driver = await this.driverRepository.findByIRacingId(dto.driverId);
if (!driver) {
this.logger.warn(`ImportRaceResultsUseCase: Driver with iRacing ID ${dto.driverId} not found for race ${raceId}.`);
throw new BusinessRuleViolationError(`Driver with iRacing ID ${dto.driverId} not found`);
}
return Result.create({
id: dto.id,
raceId: dto.raceId,
driverId: driver.id,
position: dto.position,
fastestLap: dto.fastestLap,
incidents: dto.incidents,
startPosition: dto.startPosition,
});
}),
);
this.logger.debug('ImportRaceResultsUseCase:entities created', { count: entities.length });
await this.resultRepository.createMany(entities);
this.logger.info('ImportRaceResultsUseCase:race results created', { raceId });
await this.standingRepository.recalculate(league.id);
this.logger.info('ImportRaceResultsUseCase:standings recalculated', { leagueId: league.id });
const viewModel: ImportRaceResultsSummaryViewModel = {
importedCount: results.length,
standingsRecalculated: true,
};
this.logger.debug('ImportRaceResultsUseCase:presenting view model', { viewModel });
this.presenter.present(viewModel);
} catch (error) {
this.logger.error('ImportRaceResultsUseCase:execution error', { error });
throw error;
}
}
}

View File

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

Some files were not shown because too many files have changed in this diff Show More