This commit is contained in:
2025-12-11 00:57:32 +01:00
parent 1303a14493
commit 6a427eab57
112 changed files with 6148 additions and 2272 deletions

View File

@@ -28,62 +28,6 @@ export class GetUnreadNotificationsQuery {
}
/**
* Application Query: GetNotificationsQuery
*
* Retrieves all notifications for a recipient with optional filtering.
*/
export interface GetNotificationsOptions {
includeRead?: boolean;
includeDismissed?: boolean;
limit?: number;
offset?: number;
}
export class GetNotificationsQuery {
constructor(
private readonly notificationRepository: INotificationRepository,
) {}
async execute(recipientId: string, options: GetNotificationsOptions = {}): Promise<Notification[]> {
const allNotifications = await this.notificationRepository.findByRecipientId(recipientId);
let filtered = allNotifications;
// Filter by status
if (!options.includeRead && !options.includeDismissed) {
filtered = filtered.filter(n => n.isUnread());
} else if (!options.includeDismissed) {
filtered = filtered.filter(n => !n.isDismissed());
} else if (!options.includeRead) {
filtered = filtered.filter(n => n.isUnread() || n.isDismissed());
}
// Sort by creation date (newest first)
filtered.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
// Apply pagination
if (options.offset !== undefined) {
filtered = filtered.slice(options.offset);
}
if (options.limit !== undefined) {
filtered = filtered.slice(0, options.limit);
}
return filtered;
}
}
/**
* Application Query: GetUnreadCountQuery
*
* Gets the count of unread notifications for a recipient.
*/
export class GetUnreadCountQuery {
constructor(
private readonly notificationRepository: INotificationRepository,
) {}
async execute(recipientId: string): Promise<number> {
return this.notificationRepository.countUnreadByRecipientId(recipientId);
}
}
* Additional notification query use cases (e.g., listing or counting notifications)
* can be added here in the future as needed.
*/

View File

@@ -1,45 +1,45 @@
export * from './use-cases/JoinLeagueUseCase';
export * from './use-cases/RegisterForRaceUseCase';
export * from './use-cases/WithdrawFromRaceUseCase';
export * from './use-cases/IsDriverRegisteredForRaceQuery';
export * from './use-cases/GetRaceRegistrationsQuery';
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/GetAllTeamsQuery';
export * from './use-cases/GetTeamDetailsQuery';
export * from './use-cases/GetTeamMembersQuery';
export * from './use-cases/GetTeamJoinRequestsQuery';
export * from './use-cases/GetDriverTeamQuery';
export * from './use-cases/GetLeagueStandingsQuery';
export * from './use-cases/GetLeagueDriverSeasonStatsQuery';
export * from './use-cases/GetAllLeaguesWithCapacityQuery';
export * from './use-cases/GetAllLeaguesWithCapacityAndScoringQuery';
export * from './use-cases/ListLeagueScoringPresetsQuery';
export * from './use-cases/GetLeagueScoringConfigQuery';
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/GetLeagueFullConfigQuery';
export * from './use-cases/PreviewLeagueScheduleQuery';
export * from './use-cases/GetRaceWithSOFQuery';
export * from './use-cases/GetLeagueStatsQuery';
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/GetRaceProtestsQuery';
export * from './use-cases/GetRacePenaltiesQuery';
export * from './use-cases/GetRaceProtestsUseCase';
export * from './use-cases/GetRacePenaltiesUseCase';
export * from './use-cases/RequestProtestDefenseUseCase';
export * from './use-cases/SubmitProtestDefenseUseCase';
export * from './use-cases/GetSponsorDashboardQuery';
export * from './use-cases/GetSponsorSponsorshipsQuery';
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/GetPendingSponsorshipRequestsQuery';
export * from './use-cases/GetEntitySponsorshipPricingQuery';
export * from './use-cases/GetPendingSponsorshipRequestsUseCase';
export * from './use-cases/GetEntitySponsorshipPricingUseCase';
// Export ports
export * from './ports/DriverRatingProvider';

View File

@@ -0,0 +1,27 @@
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 interface IAllRacesPagePresenter {
present(viewModel: AllRacesPageViewModel): void;
getViewModel(): AllRacesPageViewModel | null;
}

View File

@@ -0,0 +1,88 @@
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 interface IDashboardOverviewPresenter {
present(viewModel: DashboardOverviewViewModel): void;
getViewModel(): DashboardOverviewViewModel | null;
}

View File

@@ -1,4 +1,4 @@
import type { GetEntitySponsorshipPricingResultDTO } from '../use-cases/GetEntitySponsorshipPricingQuery';
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

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

View File

@@ -0,0 +1,101 @@
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;
}
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;
}
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,61 @@
import type { SessionType, RaceStatus } from '../../domain/entities/Race';
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 {
present(viewModel: RaceDetailViewModel): RaceDetailViewModel;
getViewModel(): RaceDetailViewModel | null;
}

View File

@@ -0,0 +1,40 @@
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';
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 {
present(viewModel: RaceResultsDetailViewModel): RaceResultsDetailViewModel;
getViewModel(): RaceResultsDetailViewModel | null;
}

View File

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

View File

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

View File

@@ -19,6 +19,14 @@ export interface TeamLeaderboardItemViewModel {
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 ITeamsLeaderboardPresenter {

View File

@@ -0,0 +1,32 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
/**
* 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 {
constructor(
private readonly raceRepository: IRaceRepository,
) {}
async execute(command: CancelRaceCommandDTO): Promise<void> {
const { raceId } = command;
const race = await this.raceRepository.findById(raceId);
if (!race) {
throw new Error('Race not found');
}
const cancelledRace = race.cancel();
await this.raceRepository.update(cancelledRace);
}
}

View File

@@ -0,0 +1,62 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type {
IAllRacesPagePresenter,
AllRacesPageViewModel,
AllRacesListItemViewModel,
AllRacesFilterOptionsViewModel,
} from '../presenters/IAllRacesPagePresenter';
export class GetAllRacesPageDataUseCase {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
public readonly presenter: IAllRacesPagePresenter,
) {}
async execute(): Promise<void> {
const [allRaces, allLeagues] = await Promise.all([
this.raceRepository.findAll(),
this.leagueRepository.findAll(),
]);
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,
};
this.presenter.present(viewModel);
}
}

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 { IImageService } from '../../domain/services/IImageService';
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: IImageService,
private readonly getDriverStats: (driverId: string) => DashboardDriverStatsAdapter | null,
public readonly presenter: IDashboardOverviewPresenter,
) {}
async execute(params: GetDashboardOverviewParams): 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,
};
this.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,442 @@
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { IImageService } from '../../domain/services/IImageService';
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: IImageService,
private readonly getDriverStats: (driverId: string) => ProfileDriverStatsAdapter | null,
private readonly getAllDriverRankings: () => DriverRankingEntry[],
public readonly presenter: IProfileOverviewPresenter,
) {}
async execute(params: GetProfileOverviewParams): Promise<void> {
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;
}
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);
}
private buildDriverSummary(
driver: any,
stats: ProfileDriverStatsAdapter | null,
): ProfileOverviewDriverSummaryViewModel {
const rankings = this.getAllDriverRankings();
const fallbackRank = this.computeFallbackRank(driver.id, rankings);
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,
};
}
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,
};
}
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],
favoriteTrack: tracks[hash % tracks.length],
favoriteCar: cars[hash % cars.length],
timezone: timezones[hash % timezones.length],
availableHours: hours[hash % hours.length],
lookingForTeam: hash % 3 === 0,
openToRequests: hash % 2 === 0,
};
}
}

View File

@@ -0,0 +1,159 @@
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 { IImageService } from '../../domain/services/IImageService';
import type {
IRaceDetailPresenter,
RaceDetailViewModel,
RaceDetailRaceViewModel,
RaceDetailLeagueViewModel,
RaceDetailEntryViewModel,
RaceDetailUserResultViewModel,
} from '../presenters/IRaceDetailPresenter';
/**
* 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 {
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: IImageService,
public readonly presenter: IRaceDetailPresenter,
) {}
async execute(params: GetRaceDetailQueryParams): Promise<void> {
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',
};
this.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,
registeredCount: race.registeredCount,
maxParticipants: race.maxParticipants,
};
const leagueView: RaceDetailLeagueViewModel | null = league
? {
id: league.id,
name: league.name,
description: league.description,
settings: {
maxDrivers: league.settings.maxDrivers,
qualifyingFormat: league.settings.qualifyingFormat,
},
}
: null;
const viewModel: RaceDetailViewModel = {
race: raceView,
league: leagueView,
entryList,
registration: {
isUserRegistered,
canRegister,
},
userResult: userResultView,
};
this.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,145 @@
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 { 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> {
if (!league) return {};
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,
},
};
return (
league.settings.customPoints ||
pointsSystems[league.settings.pointsSystem] ||
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,
value: p.value,
}));
}
export class GetRaceResultsDetailUseCase {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly resultRepository: IResultRepository,
private readonly driverRepository: IDriverRepository,
private readonly penaltyRepository: IPenaltyRepository,
public readonly presenter: IRaceResultsDetailPresenter,
) {}
async execute(params: GetRaceResultsDetailParams): Promise<void> {
const { raceId, driverId } = params;
const race = await this.raceRepository.findById(raceId);
if (!race) {
const errorViewModel: RaceResultsDetailViewModel = {
race: null,
league: null,
results: [],
drivers: [],
penalties: [],
pointsSystem: {},
fastestLapTime: undefined,
currentDriverId: driverId,
error: 'Race not found',
};
this.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,
fastestLapTime,
currentDriverId: effectiveCurrentDriverId,
};
this.presenter.present(viewModel);
}
}

View File

@@ -1,4 +1,3 @@
import { inject, injectable } from 'tsyringe';
import type { ITeamRepository } from '@gridpilot/racing/domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '@gridpilot/racing/domain/repositories/ITeamMembershipRepository';
import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository';
@@ -11,15 +10,19 @@ interface DriverStatsAdapter {
totalRaces: number;
}
@injectable()
/**
* Use case: GetTeamsLeaderboardUseCase
*
* Plain constructor-injected dependencies (no decorators) to keep the
* application layer framework-agnostic and compatible with test tooling.
*/
export class GetTeamsLeaderboardUseCase {
constructor(
@inject('ITeamRepository') private readonly teamRepository: ITeamRepository,
@inject('ITeamMembershipRepository')
private readonly teamRepository: ITeamRepository,
private readonly teamMembershipRepository: ITeamMembershipRepository,
@inject('IDriverRepository') private readonly driverRepository: IDriverRepository,
private readonly driverRepository: IDriverRepository,
private readonly getDriverStats: (driverId: string) => DriverStatsAdapter | null,
public readonly presenter: ITeamsLeaderboardPresenter
public readonly presenter: ITeamsLeaderboardPresenter,
) {}
async execute(): Promise<void> {

View File

@@ -0,0 +1,75 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import { Result } from '../../domain/entities/Result';
import type {
IImportRaceResultsPresenter,
ImportRaceResultsSummaryViewModel,
} from '../presenters/IImportRaceResultsPresenter';
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 {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly resultRepository: IResultRepository,
private readonly standingRepository: IStandingRepository,
public readonly presenter: IImportRaceResultsPresenter,
) {}
async execute(params: ImportRaceResultsParams): Promise<void> {
const { raceId, results } = params;
const race = await this.raceRepository.findById(raceId);
if (!race) {
throw new Error('Race not found');
}
const league = await this.leagueRepository.findById(race.leagueId);
if (!league) {
throw new Error('League not found');
}
const existing = await this.resultRepository.existsByRaceId(raceId);
if (existing) {
throw new Error('Results already exist for this race');
}
const entities = results.map((dto) =>
Result.create({
id: dto.id,
raceId: dto.raceId,
driverId: dto.driverId,
position: dto.position,
fastestLap: dto.fastestLap,
incidents: dto.incidents,
startPosition: dto.startPosition,
}),
);
await this.resultRepository.createMany(entities);
await this.standingRepository.recalculate(league.id);
const viewModel: ImportRaceResultsSummaryViewModel = {
importedCount: results.length,
standingsRecalculated: true,
};
this.presenter.present(viewModel);
}
}

View File

@@ -0,0 +1,35 @@
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { DriverDTO } from '../dto/DriverDTO';
import { EntityMappers } from '../mappers/EntityMappers';
export interface UpdateDriverProfileInput {
driverId: string;
bio?: string;
country?: string;
}
/**
* Application use case responsible for updating basic driver profile details.
* Encapsulates domain entity mutation and mapping to a DriverDTO.
*/
export class UpdateDriverProfileUseCase {
constructor(private readonly driverRepository: IDriverRepository) {}
async execute(input: UpdateDriverProfileInput): Promise<DriverDTO | null> {
const { driverId, bio, country } = input;
const existing = await this.driverRepository.findById(driverId);
if (!existing) {
return null;
}
const updated = existing.update({
bio: bio ?? existing.bio,
country: country ?? existing.country,
});
const persisted = await this.driverRepository.update(updated);
const dto = EntityMappers.toDriverDTO(persisted);
return dto ?? null;
}
}

View File

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

View File

@@ -47,10 +47,10 @@ export * from './application/dto/DriverDTO';
export * from './application/dto/LeagueDriverSeasonStatsDTO';
export * from './application/dto/LeagueScoringConfigDTO';
export * from './application/use-cases/GetSponsorDashboardQuery';
export * from './application/use-cases/GetSponsorSponsorshipsQuery';
export * from './application/use-cases/GetSponsorDashboardUseCase';
export * from './application/use-cases/GetSponsorSponsorshipsUseCase';
export * from './application/use-cases/ApplyForSponsorshipUseCase';
export * from './application/use-cases/AcceptSponsorshipRequestUseCase';
export * from './application/use-cases/RejectSponsorshipRequestUseCase';
export * from './application/use-cases/GetPendingSponsorshipRequestsQuery';
export * from './application/use-cases/GetEntitySponsorshipPricingQuery';
export * from './application/use-cases/GetPendingSponsorshipRequestsUseCase';
export * from './application/use-cases/GetEntitySponsorshipPricingUseCase';