From ec177a75cef06fd5de9eb727dc1aa8d003c3476e Mon Sep 17 00:00:00 2001
From: Marc Mintel
Date: Fri, 19 Dec 2025 21:58:03 +0100
Subject: [PATCH] fix data flow issues
---
.../InMemoryDriverExtendedProfileProvider.ts | 107 +++++++++++
.../ports/InMemoryDriverRatingProvider.ts | 3 +
apps/api/src/domain/driver/DriverProviders.ts | 13 +-
.../api/src/domain/league/LeagueController.ts | 21 ++
apps/api/src/domain/league/LeagueProviders.ts | 18 ++
apps/api/src/domain/league/LeagueService.ts | 32 ++++
.../league/dtos/GetLeagueWalletOutputDTO.ts | 59 ++++++
.../dtos/WithdrawFromLeagueWalletInputDTO.ts | 15 ++
.../dtos/WithdrawFromLeagueWalletOutputDTO.ts | 9 +
apps/website/app/auth/iracing/start/route.ts | 4 +-
apps/website/app/drivers/[id]/page.tsx | 111 +++--------
.../app/leagues/[id]/stewarding/page.tsx | 138 +++-----------
apps/website/app/leagues/[id]/wallet/page.tsx | 179 +++++-------------
.../app/races/[id]/stewarding/page.tsx | 74 +++-----
apps/website/app/races/all/page.tsx | 15 +-
apps/website/app/races/page.tsx | 31 ++-
.../lib/api/wallets/WalletsApiClient.ts | 54 ++++++
apps/website/lib/services/ServiceFactory.ts | 37 ++++
apps/website/lib/services/ServiceProvider.tsx | 9 +
.../leagues/LeagueStewardingService.ts | 93 +++++++++
.../services/leagues/LeagueWalletService.ts | 71 +++++++
.../website/lib/services/races/RaceService.ts | 43 +----
.../services/races/RaceStewardingService.ts | 36 ++++
.../view-models/LeagueStewardingViewModel.ts | 74 ++++++++
.../lib/view-models/LeagueWalletViewModel.ts | 60 ++++++
.../lib/view-models/RaceListItemViewModel.ts | 59 +++---
.../view-models/RaceStewardingViewModel.ts | 99 ++++++++++
.../lib/view-models/RacesPageViewModel.ts | 96 +++++-----
.../view-models/WalletTransactionViewModel.ts | 62 +++---
.../ports/DriverExtendedProfileProvider.ts | 24 +++
.../application/ports/DriverRatingProvider.ts | 4 +
.../ports/output/GetLeagueWalletOutputPort.ts | 23 +++
.../ports/output/ProfileOverviewOutputPort.ts | 23 ++-
.../WithdrawFromLeagueWalletOutputPort.ts | 4 +
.../use-cases/GetLeagueWalletUseCase.ts | 99 ++++++++++
.../use-cases/GetProfileOverviewUseCase.ts | 5 +-
.../WithdrawFromLeagueWalletUseCase.ts | 66 +++++++
37 files changed, 1336 insertions(+), 534 deletions(-)
create mode 100644 adapters/racing/ports/InMemoryDriverExtendedProfileProvider.ts
create mode 100644 apps/api/src/domain/league/dtos/GetLeagueWalletOutputDTO.ts
create mode 100644 apps/api/src/domain/league/dtos/WithdrawFromLeagueWalletInputDTO.ts
create mode 100644 apps/api/src/domain/league/dtos/WithdrawFromLeagueWalletOutputDTO.ts
create mode 100644 apps/website/lib/api/wallets/WalletsApiClient.ts
create mode 100644 apps/website/lib/services/leagues/LeagueStewardingService.ts
create mode 100644 apps/website/lib/services/leagues/LeagueWalletService.ts
create mode 100644 apps/website/lib/services/races/RaceStewardingService.ts
create mode 100644 apps/website/lib/view-models/LeagueStewardingViewModel.ts
create mode 100644 apps/website/lib/view-models/LeagueWalletViewModel.ts
create mode 100644 apps/website/lib/view-models/RaceStewardingViewModel.ts
create mode 100644 core/racing/application/ports/DriverExtendedProfileProvider.ts
create mode 100644 core/racing/application/ports/DriverRatingProvider.ts
create mode 100644 core/racing/application/ports/output/GetLeagueWalletOutputPort.ts
create mode 100644 core/racing/application/ports/output/WithdrawFromLeagueWalletOutputPort.ts
create mode 100644 core/racing/application/use-cases/GetLeagueWalletUseCase.ts
create mode 100644 core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase.ts
diff --git a/adapters/racing/ports/InMemoryDriverExtendedProfileProvider.ts b/adapters/racing/ports/InMemoryDriverExtendedProfileProvider.ts
new file mode 100644
index 000000000..2eba59336
--- /dev/null
+++ b/adapters/racing/ports/InMemoryDriverExtendedProfileProvider.ts
@@ -0,0 +1,107 @@
+import type { DriverExtendedProfileProvider } from '@core/racing/application/ports/DriverExtendedProfileProvider';
+import type { Logger } from '@core/shared/application';
+
+// TODO Provider doesnt exist in Clean Architecture
+// TODO Hardcoded data here must be moved to a better place
+
+export class InMemoryDriverExtendedProfileProvider implements DriverExtendedProfileProvider {
+ constructor(private readonly logger: Logger) {
+ this.logger.info('InMemoryDriverExtendedProfileProvider initialized.');
+ }
+
+ getExtendedProfile(driverId: string): {
+ socialHandles: {
+ platform: 'twitter' | 'youtube' | 'twitch' | 'discord';
+ handle: string;
+ url: string;
+ }[];
+ achievements: {
+ id: string;
+ title: string;
+ description: string;
+ icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap';
+ rarity: 'common' | 'rare' | 'epic' | 'legendary';
+ earnedAt: string;
+ }[];
+ racingStyle: string;
+ favoriteTrack: string;
+ favoriteCar: string;
+ timezone: string;
+ availableHours: string;
+ lookingForTeam: boolean;
+ openToRequests: boolean;
+ } | null {
+ this.logger.debug(`[InMemoryDriverExtendedProfileProvider] Getting extended profile for driver: ${driverId}`);
+
+ const hash = driverId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
+
+ const socialOptions: {
+ 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: {
+ id: string;
+ title: string;
+ description: string;
+ icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap';
+ rarity: 'common' | 'rare' | 'epic' | 'legendary';
+ earnedAt: string;
+ }[][] = [
+ [
+ { id: '1', title: 'First Victory', description: 'Win your first race', icon: 'trophy', rarity: 'common', earnedAt: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString() },
+ { id: '2', title: 'Clean Racer', description: '10 races without incidents', icon: 'star', rarity: 'rare', earnedAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString() },
+ { id: '3', title: 'Podium Streak', description: '5 consecutive podium finishes', icon: 'medal', rarity: 'epic', earnedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() },
+ ],
+ [
+ { id: '1', title: 'Rookie No More', description: 'Complete 25 races', icon: 'target', rarity: 'common', earnedAt: new Date(Date.now() - 120 * 24 * 60 * 60 * 1000).toISOString() },
+ { id: '2', title: 'Consistent Performer', description: 'Maintain 80%+ consistency rating', icon: 'zap', rarity: 'rare', earnedAt: new Date(Date.now() - 45 * 24 * 60 * 60 * 1000).toISOString() },
+ ],
+ [
+ { id: '1', title: 'Welcome Racer', description: 'Join GridPilot', icon: 'star', rarity: 'common', earnedAt: new Date(Date.now() - 180 * 24 * 60 * 60 * 1000).toISOString() },
+ { id: '2', title: 'Team Player', description: 'Join a racing team', icon: 'medal', rarity: 'rare', earnedAt: new Date(Date.now() - 80 * 24 * 60 * 60 * 1000).toISOString() },
+ ],
+ ];
+
+ 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 achievements = achievementSets[hash % achievementSets.length] ?? [];
+ const racingStyle = styles[hash % styles.length] ?? 'Consistent Pacer';
+ const favoriteTrack = tracks[hash % tracks.length] ?? 'Unknown Track';
+ const favoriteCar = cars[hash % cars.length] ?? 'Unknown Car';
+ const timezone = timezones[hash % timezones.length] ?? 'UTC';
+ const availableHours = hours[hash % hours.length] ?? 'Flexible schedule';
+
+ return {
+ socialHandles,
+ achievements,
+ racingStyle,
+ favoriteTrack,
+ favoriteCar,
+ timezone,
+ availableHours,
+ lookingForTeam: hash % 3 === 0,
+ openToRequests: hash % 2 === 0,
+ };
+ }
+}
\ No newline at end of file
diff --git a/adapters/racing/ports/InMemoryDriverRatingProvider.ts b/adapters/racing/ports/InMemoryDriverRatingProvider.ts
index 4a25dbdba..4f5f312d3 100644
--- a/adapters/racing/ports/InMemoryDriverRatingProvider.ts
+++ b/adapters/racing/ports/InMemoryDriverRatingProvider.ts
@@ -1,6 +1,9 @@
import type { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider';
import type { Logger } from '@core/shared/application';
+// TODO Provider doesnt exist in Clean Architecture
+// TODO Hardcoded data here must be moved to a better place
+
export class InMemoryDriverRatingProvider implements DriverRatingProvider {
constructor(private readonly logger: Logger) {
this.logger.info('InMemoryDriverRatingProvider initialized.');
diff --git a/apps/api/src/domain/driver/DriverProviders.ts b/apps/api/src/domain/driver/DriverProviders.ts
index 51b88da29..9992ffdc0 100644
--- a/apps/api/src/domain/driver/DriverProviders.ts
+++ b/apps/api/src/domain/driver/DriverProviders.ts
@@ -6,6 +6,7 @@ import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepos
import { IRankingService } from '@core/racing/domain/services/IRankingService';
import { IDriverStatsService } from '@core/racing/domain/services/IDriverStatsService';
import { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider';
+import { DriverExtendedProfileProvider } from '@core/racing/application/ports/DriverExtendedProfileProvider';
import { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
import { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
import { INotificationPreferenceRepository } from '@core/notifications/domain/repositories/INotificationPreferenceRepository';
@@ -24,6 +25,7 @@ import { InMemoryDriverRepository } from '@adapters/racing/persistence/inmemory/
import { InMemoryRankingService } from '@adapters/racing/services/InMemoryRankingService';
import { InMemoryDriverStatsService } from '@adapters/racing/services/InMemoryDriverStatsService';
import { InMemoryDriverRatingProvider } from '@adapters/racing/ports/InMemoryDriverRatingProvider';
+import { InMemoryDriverExtendedProfileProvider } from '@adapters/racing/ports/InMemoryDriverExtendedProfileProvider';
import { InMemoryImageServiceAdapter } from '@adapters/media/ports/InMemoryImageServiceAdapter';
import { InMemoryRaceRegistrationRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRegistrationRepository';
import { InMemoryNotificationPreferenceRepository } from '@adapters/notifications/persistence/inmemory/InMemoryNotificationPreferenceRepository';
@@ -34,6 +36,7 @@ export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
export const RANKING_SERVICE_TOKEN = 'IRankingService';
export const DRIVER_STATS_SERVICE_TOKEN = 'IDriverStatsService';
export const DRIVER_RATING_PROVIDER_TOKEN = 'DriverRatingProvider';
+export const DRIVER_EXTENDED_PROFILE_PROVIDER_TOKEN = 'DriverExtendedProfileProvider';
export const IMAGE_SERVICE_PORT_TOKEN = 'IImageServicePort';
export const RACE_REGISTRATION_REPOSITORY_TOKEN = 'IRaceRegistrationRepository';
export const NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN = 'INotificationPreferenceRepository';
@@ -69,6 +72,11 @@ export const DriverProviders: Provider[] = [
useFactory: (logger: Logger) => new InMemoryDriverRatingProvider(logger),
inject: [LOGGER_TOKEN],
},
+ {
+ provide: DRIVER_EXTENDED_PROFILE_PROVIDER_TOKEN,
+ useFactory: (logger: Logger) => new InMemoryDriverExtendedProfileProvider(logger),
+ inject: [LOGGER_TOKEN],
+ },
{
provide: IMAGE_SERVICE_PORT_TOKEN,
useFactory: (logger: Logger) => new InMemoryImageServiceAdapter(logger),
@@ -117,7 +125,7 @@ export const DriverProviders: Provider[] = [
},
{
provide: GET_PROFILE_OVERVIEW_USE_CASE_TOKEN,
- useFactory: (driverRepo: IDriverRepository, imageService: IImageServicePort, logger: Logger) =>
+ useFactory: (driverRepo: IDriverRepository, imageService: IImageServicePort, driverExtendedProfileProvider: DriverExtendedProfileProvider, logger: Logger) =>
new GetProfileOverviewUseCase(
driverRepo,
// TODO: Add teamRepository, teamMembershipRepository, socialRepository, etc.
@@ -125,9 +133,10 @@ export const DriverProviders: Provider[] = [
null as any, // teamMembershipRepository
null as any, // socialRepository
imageService,
+ driverExtendedProfileProvider,
() => null, // getDriverStats
() => [], // getAllDriverRankings
),
- inject: [DRIVER_REPOSITORY_TOKEN, IMAGE_SERVICE_PORT_TOKEN, LOGGER_TOKEN],
+ inject: [DRIVER_REPOSITORY_TOKEN, IMAGE_SERVICE_PORT_TOKEN, DRIVER_EXTENDED_PROFILE_PROVIDER_TOKEN, LOGGER_TOKEN],
},
];
diff --git a/apps/api/src/domain/league/LeagueController.ts b/apps/api/src/domain/league/LeagueController.ts
index 52c448467..f47ccf329 100644
--- a/apps/api/src/domain/league/LeagueController.ts
+++ b/apps/api/src/domain/league/LeagueController.ts
@@ -29,6 +29,9 @@ import { GetLeagueOwnerSummaryQueryDTO } from './dtos/GetLeagueOwnerSummaryQuery
import { GetLeagueAdminConfigQueryDTO } from './dtos/GetLeagueAdminConfigQueryDTO';
import { GetLeagueProtestsQueryDTO } from './dtos/GetLeagueProtestsQueryDTO';
import { GetLeagueSeasonsQueryDTO } from './dtos/GetLeagueSeasonsQueryDTO';
+import { GetLeagueWalletOutputDTO } from './dtos/GetLeagueWalletOutputDTO';
+import { WithdrawFromLeagueWalletInputDTO } from './dtos/WithdrawFromLeagueWalletInputDTO';
+import { WithdrawFromLeagueWalletOutputDTO } from './dtos/WithdrawFromLeagueWalletOutputDTO';
@ApiTags('leagues')
@Controller('leagues')
@@ -274,4 +277,22 @@ export class LeagueController {
async getRaces(@Param('leagueId') leagueId: string): Promise {
return this.leagueService.getRaces(leagueId);
}
+
+ @Get(':leagueId/wallet')
+ @ApiOperation({ summary: 'Get league wallet information' })
+ @ApiResponse({ status: 200, description: 'League wallet data', type: GetLeagueWalletOutputDTO })
+ async getLeagueWallet(@Param('leagueId') leagueId: string): Promise {
+ return this.leagueService.getLeagueWallet(leagueId);
+ }
+
+ @Post(':leagueId/wallet/withdraw')
+ @ApiOperation({ summary: 'Withdraw from league wallet' })
+ @ApiBody({ type: WithdrawFromLeagueWalletInputDTO })
+ @ApiResponse({ status: 200, description: 'Withdrawal processed', type: WithdrawFromLeagueWalletOutputDTO })
+ async withdrawFromLeagueWallet(
+ @Param('leagueId') leagueId: string,
+ @Body() input: WithdrawFromLeagueWalletInputDTO,
+ ): Promise {
+ return this.leagueService.withdrawFromLeagueWallet(leagueId, input);
+ }
}
diff --git a/apps/api/src/domain/league/LeagueProviders.ts b/apps/api/src/domain/league/LeagueProviders.ts
index ac9bfe29a..4949404ff 100644
--- a/apps/api/src/domain/league/LeagueProviders.ts
+++ b/apps/api/src/domain/league/LeagueProviders.ts
@@ -15,6 +15,8 @@ import { InMemoryProtestRepository } from '@adapters/racing/persistence/inmemory
import { InMemoryRaceRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRepository';
import { InMemoryDriverRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverRepository';
import { InMemoryStandingRepository } from '@adapters/racing/persistence/inmemory/InMemoryStandingRepository';
+import { InMemoryLeagueWalletRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueWalletRepository';
+import { InMemoryTransactionRepository } from '@adapters/racing/persistence/inmemory/InMemoryTransactionRepository';
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
import { listLeagueScoringPresets } from '@adapters/bootstrap/LeagueScoringPresets';
@@ -38,6 +40,8 @@ import { GetLeagueMembershipsUseCase } from '@core/racing/application/use-cases/
import { GetLeagueScheduleUseCase } from '@core/racing/application/use-cases/GetLeagueScheduleUseCase';
import { GetLeagueStatsUseCase } from '@core/racing/application/use-cases/GetLeagueStatsUseCase';
import { GetLeagueAdminPermissionsUseCase } from '@core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase';
+import { GetLeagueWalletUseCase } from '@core/racing/application/use-cases/GetLeagueWalletUseCase';
+import { WithdrawFromLeagueWalletUseCase } from '@core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase';
// Define injection tokens
export const LEAGUE_REPOSITORY_TOKEN = 'ILeagueRepository';
@@ -50,6 +54,8 @@ export const GAME_REPOSITORY_TOKEN = 'IGameRepository';
export const PROTEST_REPOSITORY_TOKEN = 'IProtestRepository';
export const RACE_REPOSITORY_TOKEN = 'IRaceRepository';
export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
+export const LEAGUE_WALLET_REPOSITORY_TOKEN = 'ILeagueWalletRepository';
+export const TRANSACTION_REPOSITORY_TOKEN = 'ITransactionRepository';
export const LOGGER_TOKEN = 'Logger'; // Already defined in AuthProviders, but good to have here too
export const GET_LEAGUE_STANDINGS_USE_CASE = 'GetLeagueStandingsUseCase';
@@ -105,6 +111,16 @@ export const LeagueProviders: Provider[] = [
useFactory: (logger: Logger) => new InMemoryDriverRepository(logger),
inject: [LOGGER_TOKEN],
},
+ {
+ provide: LEAGUE_WALLET_REPOSITORY_TOKEN,
+ useFactory: (logger: Logger) => new InMemoryLeagueWalletRepository(logger),
+ inject: [LOGGER_TOKEN],
+ },
+ {
+ provide: TRANSACTION_REPOSITORY_TOKEN,
+ useFactory: (logger: Logger) => new InMemoryTransactionRepository(logger),
+ inject: [LOGGER_TOKEN],
+ },
{
provide: LOGGER_TOKEN,
useClass: ConsoleLogger,
@@ -132,6 +148,8 @@ export const LeagueProviders: Provider[] = [
GetLeagueScheduleUseCase,
GetLeagueStatsUseCase,
GetLeagueAdminPermissionsUseCase,
+ GetLeagueWalletUseCase,
+ WithdrawFromLeagueWalletUseCase,
{
provide: ListLeagueScoringPresetsUseCase,
useFactory: () => new ListLeagueScoringPresetsUseCase(listLeagueScoringPresets()),
diff --git a/apps/api/src/domain/league/LeagueService.ts b/apps/api/src/domain/league/LeagueService.ts
index 7c2656c02..0d827cd5d 100644
--- a/apps/api/src/domain/league/LeagueService.ts
+++ b/apps/api/src/domain/league/LeagueService.ts
@@ -58,6 +58,8 @@ import { RejectLeagueJoinRequestUseCase } from '@core/racing/application/use-cas
import { RemoveLeagueMemberUseCase } from '@core/racing/application/use-cases/RemoveLeagueMemberUseCase';
import { TransferLeagueOwnershipUseCase } from '@core/racing/application/use-cases/TransferLeagueOwnershipUseCase';
import { UpdateLeagueMemberRoleUseCase } from '@core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase';
+import { GetLeagueWalletUseCase } from '@core/racing/application/use-cases/GetLeagueWalletUseCase';
+import { WithdrawFromLeagueWalletUseCase } from '@core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase';
// API Presenters
import { AllLeaguesWithCapacityPresenter } from './presenters/AllLeaguesWithCapacityPresenter';
@@ -108,6 +110,8 @@ export class LeagueService {
private readonly getLeagueMembershipsUseCase: GetLeagueMembershipsUseCase,
private readonly getLeagueScheduleUseCase: GetLeagueScheduleUseCase,
private readonly getLeagueAdminPermissionsUseCase: GetLeagueAdminPermissionsUseCase,
+ private readonly getLeagueWalletUseCase: GetLeagueWalletUseCase,
+ private readonly withdrawFromLeagueWalletUseCase: WithdrawFromLeagueWalletUseCase,
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
) {}
@@ -438,4 +442,32 @@ export class LeagueService {
races: [],
};
}
+
+ async getLeagueWallet(leagueId: string): Promise {
+ this.logger.debug('Getting league wallet', { leagueId });
+ const result = await this.getLeagueWalletUseCase.execute({ leagueId });
+ if (result.isErr()) {
+ throw new Error(result.unwrapErr().message);
+ }
+ return result.unwrap();
+ }
+
+ async withdrawFromLeagueWallet(leagueId: string, input: WithdrawFromLeagueWalletInputDTO): Promise {
+ this.logger.debug('Withdrawing from league wallet', { leagueId, amount: input.amount });
+ const result = await this.withdrawFromLeagueWalletUseCase.execute({
+ leagueId,
+ amount: input.amount,
+ currency: input.currency,
+ seasonId: input.seasonId,
+ destinationAccount: input.destinationAccount,
+ });
+ if (result.isErr()) {
+ const error = result.unwrapErr();
+ if (error.code === 'WITHDRAWAL_NOT_ALLOWED') {
+ return { success: false, message: error.message };
+ }
+ throw new Error(error.message);
+ }
+ return result.unwrap();
+ }
}
diff --git a/apps/api/src/domain/league/dtos/GetLeagueWalletOutputDTO.ts b/apps/api/src/domain/league/dtos/GetLeagueWalletOutputDTO.ts
new file mode 100644
index 000000000..f58939db2
--- /dev/null
+++ b/apps/api/src/domain/league/dtos/GetLeagueWalletOutputDTO.ts
@@ -0,0 +1,59 @@
+import { ApiProperty } from '@nestjs/swagger';
+
+export class WalletTransactionDTO {
+ @ApiProperty()
+ id: string;
+
+ @ApiProperty({ enum: ['sponsorship', 'membership', 'withdrawal', 'prize'] })
+ type: 'sponsorship' | 'membership' | 'withdrawal' | 'prize';
+
+ @ApiProperty()
+ description: string;
+
+ @ApiProperty()
+ amount: number;
+
+ @ApiProperty()
+ fee: number;
+
+ @ApiProperty()
+ netAmount: number;
+
+ @ApiProperty()
+ date: string;
+
+ @ApiProperty({ enum: ['completed', 'pending', 'failed'] })
+ status: 'completed' | 'pending' | 'failed';
+
+ @ApiProperty({ required: false })
+ reference?: string;
+}
+
+export class GetLeagueWalletOutputDTO {
+ @ApiProperty()
+ balance: number;
+
+ @ApiProperty()
+ currency: string;
+
+ @ApiProperty()
+ totalRevenue: number;
+
+ @ApiProperty()
+ totalFees: number;
+
+ @ApiProperty()
+ totalWithdrawals: number;
+
+ @ApiProperty()
+ pendingPayouts: number;
+
+ @ApiProperty()
+ canWithdraw: boolean;
+
+ @ApiProperty({ required: false })
+ withdrawalBlockReason?: string;
+
+ @ApiProperty({ type: [WalletTransactionDTO] })
+ transactions: WalletTransactionDTO[];
+}
\ No newline at end of file
diff --git a/apps/api/src/domain/league/dtos/WithdrawFromLeagueWalletInputDTO.ts b/apps/api/src/domain/league/dtos/WithdrawFromLeagueWalletInputDTO.ts
new file mode 100644
index 000000000..9fdf43b0d
--- /dev/null
+++ b/apps/api/src/domain/league/dtos/WithdrawFromLeagueWalletInputDTO.ts
@@ -0,0 +1,15 @@
+import { ApiProperty } from '@nestjs/swagger';
+
+export class WithdrawFromLeagueWalletInputDTO {
+ @ApiProperty()
+ amount: number;
+
+ @ApiProperty()
+ currency: string;
+
+ @ApiProperty()
+ seasonId: string;
+
+ @ApiProperty()
+ destinationAccount: string;
+}
\ No newline at end of file
diff --git a/apps/api/src/domain/league/dtos/WithdrawFromLeagueWalletOutputDTO.ts b/apps/api/src/domain/league/dtos/WithdrawFromLeagueWalletOutputDTO.ts
new file mode 100644
index 000000000..74d947be5
--- /dev/null
+++ b/apps/api/src/domain/league/dtos/WithdrawFromLeagueWalletOutputDTO.ts
@@ -0,0 +1,9 @@
+import { ApiProperty } from '@nestjs/swagger';
+
+export class WithdrawFromLeagueWalletOutputDTO {
+ @ApiProperty()
+ success: boolean;
+
+ @ApiProperty({ required: false })
+ message?: string;
+}
\ No newline at end of file
diff --git a/apps/website/app/auth/iracing/start/route.ts b/apps/website/app/auth/iracing/start/route.ts
index 44fa00a96..d5e985de2 100644
--- a/apps/website/app/auth/iracing/start/route.ts
+++ b/apps/website/app/auth/iracing/start/route.ts
@@ -1,13 +1,13 @@
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
-import { apiClient } from '../../../../lib/apiClient';
+import { api } from '../../../../lib/api';
export async function GET(request: Request) {
const url = new URL(request.url);
const returnTo = url.searchParams.get('returnTo') ?? undefined;
- const redirectUrl = apiClient.auth.getIracingAuthUrl(returnTo);
+ const redirectUrl = api.auth.getIracingAuthUrl(returnTo);
// For now, generate a simple state - in production this should be cryptographically secure
const state = Math.random().toString(36).substring(2, 15);
diff --git a/apps/website/app/drivers/[id]/page.tsx b/apps/website/app/drivers/[id]/page.tsx
index 5909b1e11..541e058ae 100644
--- a/apps/website/app/drivers/[id]/page.tsx
+++ b/apps/website/app/drivers/[id]/page.tsx
@@ -92,67 +92,6 @@ interface TeamMembershipInfo {
// DEMO DATA
// ============================================================================
-function getDemoExtendedProfile(driverId: string): DriverExtendedProfile {
- const hash = driverId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
-
- const socialOptions: SocialHandle[][] = [
- [
- { 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: Achievement[][] = [
- [
- { 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: '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: '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 achievements = achievementSets[hash % achievementSets.length] ?? [];
- const racingStyle = styles[hash % styles.length] ?? 'Consistent Pacer';
- const favoriteTrack = tracks[hash % tracks.length] ?? 'Unknown Track';
- const favoriteCar = cars[hash % cars.length] ?? 'Unknown Car';
- const timezone = timezones[hash % timezones.length] ?? 'UTC';
- const availableHours = hours[hash % hours.length] ?? 'Flexible schedule';
-
- return {
- socialHandles,
- achievements,
- racingStyle,
- favoriteTrack,
- favoriteCar,
- timezone,
- availableHours,
- lookingForTeam: hash % 3 === 0,
- openToRequests: hash % 2 === 0,
- };
-}
// ============================================================================
// HELPERS
@@ -428,29 +367,33 @@ export default function DriverDetailPage() {
);
}
- const demoExtended = getDemoExtendedProfile(driverProfile.currentDriver.id);
- const extendedProfile: DriverExtendedProfile = {
- socialHandles: driverProfile?.extendedProfile?.socialHandles ?? demoExtended.socialHandles,
- achievements:
- driverProfile?.extendedProfile?.achievements
- ? driverProfile.extendedProfile.achievements.map((achievement) => ({
- id: achievement.id,
- title: achievement.title,
- description: achievement.description,
- icon: achievement.icon,
- rarity: achievement.rarity,
- earnedAt: new Date(achievement.earnedAt),
- }))
- : demoExtended.achievements,
- racingStyle: driverProfile?.extendedProfile?.racingStyle ?? demoExtended.racingStyle,
- favoriteTrack: driverProfile?.extendedProfile?.favoriteTrack ?? demoExtended.favoriteTrack,
- favoriteCar: driverProfile?.extendedProfile?.favoriteCar ?? demoExtended.favoriteCar,
- timezone: driverProfile?.extendedProfile?.timezone ?? demoExtended.timezone,
- availableHours: driverProfile?.extendedProfile?.availableHours ?? demoExtended.availableHours,
- lookingForTeam:
- driverProfile?.extendedProfile?.lookingForTeam ?? demoExtended.lookingForTeam,
- openToRequests:
- driverProfile?.extendedProfile?.openToRequests ?? demoExtended.openToRequests,
+ const extendedProfile: DriverExtendedProfile = driverProfile.extendedProfile ? {
+ socialHandles: driverProfile.extendedProfile.socialHandles,
+ achievements: driverProfile.extendedProfile.achievements.map((achievement) => ({
+ id: achievement.id,
+ title: achievement.title,
+ description: achievement.description,
+ icon: achievement.icon,
+ rarity: achievement.rarity,
+ earnedAt: new Date(achievement.earnedAt),
+ })),
+ racingStyle: driverProfile.extendedProfile.racingStyle,
+ favoriteTrack: driverProfile.extendedProfile.favoriteTrack,
+ favoriteCar: driverProfile.extendedProfile.favoriteCar,
+ timezone: driverProfile.extendedProfile.timezone,
+ availableHours: driverProfile.extendedProfile.availableHours,
+ lookingForTeam: driverProfile.extendedProfile.lookingForTeam,
+ openToRequests: driverProfile.extendedProfile.openToRequests,
+ } : {
+ socialHandles: [],
+ achievements: [],
+ racingStyle: 'Unknown',
+ favoriteTrack: 'Unknown',
+ favoriteCar: 'Unknown',
+ timezone: 'UTC',
+ availableHours: 'Flexible',
+ lookingForTeam: false,
+ openToRequests: false,
};
const stats = driverProfile?.stats || null;
const globalRank = driverProfile?.currentDriver?.globalRank || 1;
diff --git a/apps/website/app/leagues/[id]/stewarding/page.tsx b/apps/website/app/leagues/[id]/stewarding/page.tsx
index 4d1e4b642..cbfec099d 100644
--- a/apps/website/app/leagues/[id]/stewarding/page.tsx
+++ b/apps/website/app/leagues/[id]/stewarding/page.tsx
@@ -7,6 +7,7 @@ import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useServices } from '@/lib/services/ServiceProvider';
+import { LeagueStewardingViewModel } from '@/lib/view-models/LeagueStewardingViewModel';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import {
AlertCircle,
@@ -27,32 +28,13 @@ import { useEffect, useMemo, useState } from 'react';
// Local type definitions to replace core imports
type PenaltyType = 'time_penalty' | 'grid_penalty' | 'points_deduction' | 'disqualification' | 'warning' | 'license_points';
-type DriverDTO = {
- id: string;
- name: string;
- avatarUrl?: string;
- iracingId?: string;
- rating?: number;
-};
-
-interface RaceWithProtests {
- race: any;
- pendingProtests: any[];
- resolvedProtests: any[];
- penalties: any[];
-}
-
export default function LeagueStewardingPage() {
const params = useParams();
const leagueId = params.id as string;
const currentDriverId = useEffectiveDriverId();
- const { raceService, protestService, driverService, leagueMembershipService, penaltyService } = useServices();
+ const { leagueStewardingService, leagueMembershipService } = useServices();
- const [races, setRaces] = useState([]);
- const [protestsByRace, setProtestsByRace] = useState>({});
- const [penaltiesByRace, setPenaltiesByRace] = useState>({});
- const [driversById, setDriversById] = useState>({});
- const [allDrivers, setAllDrivers] = useState([]);
+ const [stewardingData, setStewardingData] = useState(null);
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
@@ -72,52 +54,13 @@ export default function LeagueStewardingPage() {
async function loadData() {
setLoading(true);
try {
- // Get all races for this league
- const leagueRaces = await raceService.findByLeagueId(leagueId);
- setRaces(leagueRaces);
-
- // Get protests and penalties for each race
- const protestsMap: Record = {};
- const penaltiesMap: Record = {};
- const driverIds = new Set();
-
- for (const race of leagueRaces) {
- const raceProtests = await protestService.findByRaceId(race.id);
- const racePenalties = await penaltyService.findByRaceId(race.id);
-
- protestsMap[race.id] = raceProtests;
- penaltiesMap[race.id] = racePenalties;
-
- // Collect driver IDs
- raceProtests.forEach((p: any) => {
- driverIds.add(p.protestingDriverId);
- driverIds.add(p.accusedDriverId);
- });
- racePenalties.forEach((p: any) => {
- driverIds.add(p.driverId);
- });
- }
-
- setProtestsByRace(protestsMap);
- setPenaltiesByRace(penaltiesMap);
-
- // Load driver info
- const driverEntities = await driverService.findByIds(Array.from(driverIds));
- const byId: Record = {};
- driverEntities.forEach((driver) => {
- if (driver) {
- byId[driver.id] = driver;
- }
- });
- setDriversById(byId);
- setAllDrivers(Object.values(byId));
+ const data = await leagueStewardingService.getLeagueStewardingData(leagueId);
+ setStewardingData(data);
// Auto-expand races with pending protests
const racesWithPending = new Set();
- Object.entries(protestsMap).forEach(([raceId, protests]) => {
- if (protests.some((p: any) => p.status === 'pending' || p.status === 'under_review')) {
- racesWithPending.add(raceId);
- }
+ data.pendingRaces.forEach(race => {
+ racesWithPending.add(race.race.id);
});
setExpandedRaces(racesWithPending);
} catch (err) {
@@ -130,34 +73,12 @@ export default function LeagueStewardingPage() {
if (isAdmin) {
loadData();
}
- }, [leagueId, isAdmin, raceService, protestService, driverService, penaltyService]);
-
- // Compute race data with protest/penalty info
- const racesWithData = useMemo((): RaceWithProtests[] => {
- return races.map(race => {
- const protests = protestsByRace[race.id] || [];
- const penalties = penaltiesByRace[race.id] || [];
- return {
- race,
- pendingProtests: protests.filter(p => p.status === 'pending' || p.status === 'under_review'),
- resolvedProtests: protests.filter(p => p.status === 'upheld' || p.status === 'dismissed' || p.status === 'withdrawn'),
- penalties
- };
- }).sort((a, b) => b.race.scheduledAt.getTime() - a.race.scheduledAt.getTime());
- }, [races, protestsByRace, penaltiesByRace]);
+ }, [leagueId, isAdmin, leagueStewardingService]);
// Filter races based on active tab
const filteredRaces = useMemo(() => {
- if (activeTab === 'pending') {
- return racesWithData.filter(r => r.pendingProtests.length > 0);
- }
- return racesWithData.filter(r => r.resolvedProtests.length > 0 || r.penalties.length > 0);
- }, [racesWithData, activeTab]);
-
- // Stats
- const totalPending = racesWithData.reduce((sum, r) => sum + r.pendingProtests.length, 0);
- const totalResolved = racesWithData.reduce((sum, r) => sum + r.resolvedProtests.length, 0);
- const totalPenalties = racesWithData.reduce((sum, r) => sum + r.penalties.length, 0);
+ return activeTab === 'pending' ? stewardingData?.pendingRaces ?? [] : stewardingData?.historyRaces ?? [];
+ }, [stewardingData, activeTab]);
const handleAcceptProtest = async (
protestId: string,
@@ -165,22 +86,23 @@ export default function LeagueStewardingPage() {
penaltyValue: number,
stewardNotes: string
) => {
- await protestService.reviewProtest({
+ await leagueStewardingService.reviewProtest({
protestId,
stewardId: currentDriverId,
decision: 'uphold',
decisionNotes: stewardNotes,
});
- // Find the protest
+ // Find the protest to get details for penalty
let foundProtest: any | undefined;
- Object.values(protestsByRace).forEach(protests => {
- const p = protests.find(pr => pr.id === protestId);
- if (p) foundProtest = p;
+ stewardingData?.racesWithData.forEach(raceData => {
+ const p = raceData.pendingProtests.find(pr => pr.id === protestId) ||
+ raceData.resolvedProtests.find(pr => pr.id === protestId);
+ if (p) foundProtest = { ...p, raceId: raceData.race.id };
});
if (foundProtest) {
- await penaltyService.applyPenalty({
+ await leagueStewardingService.applyPenalty({
raceId: foundProtest.raceId,
driverId: foundProtest.accusedDriverId,
stewardId: currentDriverId,
@@ -194,7 +116,7 @@ export default function LeagueStewardingPage() {
};
const handleRejectProtest = async (protestId: string, stewardNotes: string) => {
- await protestService.reviewProtest({
+ await leagueStewardingService.reviewProtest({
protestId,
stewardId: currentDriverId,
decision: 'dismiss',
@@ -260,28 +182,28 @@ export default function LeagueStewardingPage() {
{/* Stats summary */}
- {!loading && (
+ {!loading && stewardingData && (
Pending Review
-
{totalPending}
+
{stewardingData.totalPending}
Resolved
-
{totalResolved}
+
{stewardingData.totalResolved}
Penalties
-
{totalPenalties}
+
{stewardingData.totalPenalties}
)}
@@ -298,9 +220,9 @@ export default function LeagueStewardingPage() {
}`}
>
Pending Protests
- {totalPending > 0 && (
+ {stewardingData && stewardingData.totalPending > 0 && (
- {totalPending}
+ {stewardingData.totalPending}
)}
@@ -380,8 +302,8 @@ export default function LeagueStewardingPage() {
) : (
<>
{displayProtests.map((protest) => {
- const protester = driversById[protest.protestingDriverId];
- const accused = driversById[protest.accusedDriverId];
+ const protester = stewardingData!.driverMap[protest.protestingDriverId];
+ const accused = stewardingData!.driverMap[protest.accusedDriverId];
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24));
const isUrgent = daysSinceFiled > 2 && (protest.status === 'pending' || protest.status === 'under_review');
@@ -443,7 +365,7 @@ export default function LeagueStewardingPage() {
})}
{activeTab === 'history' && penalties.map((penalty) => {
- const driver = driversById[penalty.driverId];
+ const driver = stewardingData!.driverMap[penalty.driverId];
return (
)}
- {showQuickPenaltyModal && (
+ {showQuickPenaltyModal && stewardingData && (
setShowQuickPenaltyModal(false)}
adminId={currentDriverId}
- races={races.map(r => ({ id: r.id, track: r.track, scheduledAt: r.scheduledAt }))}
+ races={stewardingData.racesWithData.map(r => ({ id: r.race.id, track: r.race.track, scheduledAt: r.race.scheduledAt }))}
/>
)}
diff --git a/apps/website/app/leagues/[id]/wallet/page.tsx b/apps/website/app/leagues/[id]/wallet/page.tsx
index b61ae3ba2..ca39f0797 100644
--- a/apps/website/app/leagues/[id]/wallet/page.tsx
+++ b/apps/website/app/leagues/[id]/wallet/page.tsx
@@ -4,11 +4,13 @@ import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
-import {
- Wallet,
- DollarSign,
- ArrowUpRight,
- ArrowDownLeft,
+import { useServices } from '@/lib/services/ServiceProvider';
+import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel';
+import {
+ Wallet,
+ DollarSign,
+ ArrowUpRight,
+ ArrowDownLeft,
Clock,
AlertTriangle,
CheckCircle,
@@ -19,102 +21,9 @@ import {
Calendar
} from 'lucide-react';
-interface Transaction {
- id: string;
- type: 'sponsorship' | 'membership' | 'withdrawal' | 'prize';
- description: string;
- amount: number;
- fee: number;
- netAmount: number;
- date: Date;
- status: 'completed' | 'pending' | 'failed';
- reference?: string;
-}
-
-interface WalletData {
- balance: number;
- currency: string;
- totalRevenue: number;
- totalFees: number;
- totalWithdrawals: number;
- pendingPayouts: number;
- transactions: Transaction[];
- canWithdraw: boolean;
- withdrawalBlockReason?: string;
-}
-
-// Mock data for demonstration
-const MOCK_WALLET: WalletData = {
- balance: 2450.00,
- currency: 'USD',
- totalRevenue: 3200.00,
- totalFees: 320.00,
- totalWithdrawals: 430.00,
- pendingPayouts: 150.00,
- canWithdraw: false,
- withdrawalBlockReason: 'Season 2 is still active. Withdrawals are available after season completion.',
- transactions: [
- {
- id: 'txn-1',
- type: 'sponsorship',
- description: 'Main Sponsor - TechCorp',
- amount: 1200.00,
- fee: 120.00,
- netAmount: 1080.00,
- date: new Date('2025-12-01'),
- status: 'completed',
- reference: 'SP-2025-001',
- },
- {
- id: 'txn-2',
- type: 'sponsorship',
- description: 'Secondary Sponsor - RaceFuel',
- amount: 400.00,
- fee: 40.00,
- netAmount: 360.00,
- date: new Date('2025-12-01'),
- status: 'completed',
- reference: 'SP-2025-002',
- },
- {
- id: 'txn-3',
- type: 'membership',
- description: 'Season Fee - 32 drivers',
- amount: 1600.00,
- fee: 160.00,
- netAmount: 1440.00,
- date: new Date('2025-11-15'),
- status: 'completed',
- reference: 'MF-2025-032',
- },
- {
- id: 'txn-4',
- type: 'withdrawal',
- description: 'Bank Transfer - Season 1 Payout',
- amount: -430.00,
- fee: 0,
- netAmount: -430.00,
- date: new Date('2025-10-30'),
- status: 'completed',
- reference: 'WD-2025-001',
- },
- {
- id: 'txn-5',
- type: 'prize',
- description: 'Championship Prize Pool (reserved)',
- amount: -150.00,
- fee: 0,
- netAmount: -150.00,
- date: new Date('2025-12-05'),
- status: 'pending',
- reference: 'PZ-2025-001',
- },
- ],
-};
-
-function TransactionRow({ transaction }: { transaction: Transaction }) {
+function TransactionRow({ transaction }: { transaction: any }) {
const isIncoming = transaction.amount > 0;
-
+
const typeIcons = {
sponsorship: DollarSign,
membership: CreditCard,
@@ -158,13 +67,13 @@ function TransactionRow({ transaction }: { transaction: Transaction }) {
>
)}
•
- {transaction.date.toLocaleDateString()}
+ {transaction.formattedDate}
- {isIncoming ? '+' : ''}{transaction.amount < 0 ? '-' : ''}${Math.abs(transaction.amount).toFixed(2)}
+ {transaction.formattedAmount}
{transaction.fee > 0 && (
@@ -178,36 +87,48 @@ function TransactionRow({ transaction }: { transaction: Transaction }) {
export default function LeagueWalletPage() {
const params = useParams();
- const [wallet, setWallet] = useState
(MOCK_WALLET);
+ const { leagueWalletService } = useServices();
+ const [wallet, setWallet] = useState(null);
const [withdrawAmount, setWithdrawAmount] = useState('');
const [showWithdrawModal, setShowWithdrawModal] = useState(false);
const [processing, setProcessing] = useState(false);
const [filterType, setFilterType] = useState<'all' | 'sponsorship' | 'membership' | 'withdrawal' | 'prize'>('all');
- const filteredTransactions = wallet.transactions.filter(
- t => filterType === 'all' || t.type === filterType
- );
+ useEffect(() => {
+ const loadWallet = async () => {
+ if (params.id) {
+ try {
+ const walletData = await leagueWalletService.getWalletForLeague(params.id as string);
+ setWallet(walletData);
+ } catch (error) {
+ console.error('Failed to load wallet:', error);
+ }
+ }
+ };
+ loadWallet();
+ }, [params.id, leagueWalletService]);
+
+ if (!wallet) {
+ return Loading...
;
+ }
+
+ const filteredTransactions = wallet.getFilteredTransactions(filterType);
const handleWithdraw = async () => {
if (!withdrawAmount || parseFloat(withdrawAmount) <= 0) return;
-
+
setProcessing(true);
try {
- const response = await fetch(`/api/wallets/${params.id}/withdraw`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- amount: parseFloat(withdrawAmount),
- currency: wallet.currency,
- seasonId: 'season-2', // Current active season
- destinationAccount: 'bank-account-***1234',
- }),
- });
+ const result = await leagueWalletService.withdraw(
+ params.id as string,
+ parseFloat(withdrawAmount),
+ wallet.currency,
+ 'season-2', // Current active season
+ 'bank-account-***1234'
+ );
- const result = await response.json();
-
- if (!response.ok) {
- alert(result.reason || result.error || 'Withdrawal failed');
+ if (!result.success) {
+ alert(result.message || 'Withdrawal failed');
return;
}
@@ -215,6 +136,8 @@ export default function LeagueWalletPage() {
setShowWithdrawModal(false);
setWithdrawAmount('');
// Refresh wallet data
+ const updatedWallet = await leagueWalletService.getWalletForLeague(params.id as string);
+ setWallet(updatedWallet);
} catch (err) {
console.error('Withdrawal error:', err);
alert('Failed to process withdrawal');
@@ -236,7 +159,7 @@ export default function LeagueWalletPage() {
Export
-
-
${wallet.balance.toFixed(2)}
+
{wallet.formattedBalance}
Available Balance
@@ -280,7 +203,7 @@ export default function LeagueWalletPage() {
-
${wallet.totalRevenue.toFixed(2)}
+
{wallet.formattedTotalRevenue}
Total Revenue
@@ -292,7 +215,7 @@ export default function LeagueWalletPage() {
-
${wallet.totalFees.toFixed(2)}
+
{wallet.formattedTotalFees}
Platform Fees (10%)
@@ -304,7 +227,7 @@ export default function LeagueWalletPage() {
-
${wallet.pendingPayouts.toFixed(2)}
+
{wallet.formattedPendingPayouts}
Pending Payouts
@@ -396,7 +319,7 @@ export default function LeagueWalletPage() {
Available for Withdrawal
- ${wallet.balance.toFixed(2)}
+ {wallet.formattedBalance}
Available after Season 2 ends (estimated: Jan 15, 2026)
@@ -434,7 +357,7 @@ export default function LeagueWalletPage() {
/>
- Available: ${wallet.balance.toFixed(2)}
+ Available: {wallet.formattedBalance}
diff --git a/apps/website/app/races/[id]/stewarding/page.tsx b/apps/website/app/races/[id]/stewarding/page.tsx
index 747a6bbe9..83965c5a8 100644
--- a/apps/website/app/races/[id]/stewarding/page.tsx
+++ b/apps/website/app/races/[id]/stewarding/page.tsx
@@ -4,8 +4,8 @@ import Breadcrumbs from '@/components/layout/Breadcrumbs';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
-import type { RacePenaltiesViewModel, RaceProtestsViewModel } from '@/lib/apiClient';
-import { apiClient } from '@/lib/apiClient';
+import { useServices } from '@/lib/services/ServiceProvider';
+import { RaceStewardingViewModel } from '@/lib/view-models/RaceStewardingViewModel';
import {
AlertCircle,
AlertTriangle,
@@ -24,13 +24,11 @@ import { useEffect, useState } from 'react';
export default function RaceStewardingPage() {
const params = useParams();
const router = useRouter();
+ const { raceStewardingService } = useServices();
const raceId = params.id as string;
const currentDriverId = useEffectiveDriverId();
-
- const [race, setRace] = useState(null); // TODO: Define proper race type
- const [league, setLeague] = useState(null); // TODO: Define proper league type
- const [protestsData, setProtestsData] = useState(null);
- const [penaltiesData, setPenaltiesData] = useState(null);
+
+ const [stewardingData, setStewardingData] = useState(null);
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [activeTab, setActiveTab] = useState<'pending' | 'resolved' | 'penalties'>('pending');
@@ -39,24 +37,13 @@ export default function RaceStewardingPage() {
async function loadData() {
setLoading(true);
try {
- // Get race detail for basic info
- const raceDetail = await apiClient.races.getDetail(raceId, currentDriverId);
- setRace(raceDetail.race);
- setLeague(raceDetail.league);
+ const data = await raceStewardingService.getRaceStewardingData(raceId, currentDriverId);
+ setStewardingData(data);
- if (raceDetail.league) {
+ if (data.league) {
// TODO: Implement admin check via API
setIsAdmin(true);
}
-
- // Get protests and penalties
- const [protestsData, penaltiesData] = await Promise.all([
- apiClient.races.getProtests(raceId),
- apiClient.races.getPenalties(raceId),
- ]);
-
- setProtestsData(protestsData);
- setPenaltiesData(penaltiesData);
} catch (err) {
console.error('Failed to load data:', err);
} finally {
@@ -65,17 +52,10 @@ export default function RaceStewardingPage() {
}
loadData();
- }, [raceId, currentDriverId]);
+ }, [raceId, currentDriverId, raceStewardingService]);
- const pendingProtests = protestsData?.protests.filter(
- (p) => p.status === 'pending' || p.status === 'under_review',
- ) ?? [];
- const resolvedProtests = protestsData?.protests.filter(
- (p) =>
- p.status === 'upheld' ||
- p.status === 'dismissed' ||
- p.status === 'withdrawn',
- ) ?? [];
+ const pendingProtests = stewardingData?.pendingProtests ?? [];
+ const resolvedProtests = stewardingData?.resolvedProtests ?? [];
const getStatusBadge = (status: string) => {
switch (status) {
@@ -131,7 +111,7 @@ export default function RaceStewardingPage() {
);
}
- if (!race) {
+ if (!stewardingData?.race) {
return (
@@ -158,7 +138,7 @@ export default function RaceStewardingPage() {
const breadcrumbItems = [
{ label: 'Races', href: '/races' },
- { label: race.track, href: `/races/${race.id}` },
+ { label: stewardingData?.race?.track || 'Race', href: `/races/${raceId}` },
{ label: 'Stewarding' },
];
@@ -186,9 +166,9 @@ export default function RaceStewardingPage() {
Stewarding
-
- {race.track} • {formatDate(race.scheduledAt)}
-
+
+ {stewardingData?.race?.track} • {stewardingData?.race?.scheduledAt ? formatDate(stewardingData.race.scheduledAt) : ''}
+
@@ -199,21 +179,21 @@ export default function RaceStewardingPage() {
Pending
- {pendingProtests.length}
+ {stewardingData?.pendingCount ?? 0}
Resolved
-
{resolvedProtests.length}
+
{stewardingData?.resolvedCount ?? 0}
Penalties
-
{penaltiesData?.penalties.length ?? 0}
+
{stewardingData?.penaltiesCount ?? 0}
@@ -272,8 +252,8 @@ export default function RaceStewardingPage() {
) : (
pendingProtests.map((protest) => {
- const protester = protestsData?.driverMap[protest.protestingDriverId];
- const accused = protestsData?.driverMap[protest.accusedDriverId];
+ const protester = stewardingData?.driverMap[protest.protestingDriverId];
+ const accused = stewardingData?.driverMap[protest.accusedDriverId];
const daysSinceFiled = Math.floor(
(Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24)
);
@@ -330,9 +310,9 @@ export default function RaceStewardingPage() {
{protest.incident.description}
- {isAdmin && league && (
+ {isAdmin && stewardingData?.league && (
@@ -359,8 +339,8 @@ export default function RaceStewardingPage() {
) : (
resolvedProtests.map((protest) => {
- const protester = protestsData?.driverMap[protest.protestingDriverId];
- const accused = protestsData?.driverMap[protest.accusedDriverId];
+ const protester = stewardingData?.driverMap[protest.protestingDriverId];
+ const accused = stewardingData?.driverMap[protest.accusedDriverId];
return (
@@ -421,8 +401,8 @@ export default function RaceStewardingPage() {
) : (
- penaltiesData?.penalties.map((penalty) => {
- const driver = penaltiesData?.driverMap[penalty.driverId];
+ stewardingData?.penalties.map((penalty) => {
+ const driver = stewardingData?.driverMap[penalty.driverId];
return (
diff --git a/apps/website/app/races/all/page.tsx b/apps/website/app/races/all/page.tsx
index 299c0c48f..dfc056800 100644
--- a/apps/website/app/races/all/page.tsx
+++ b/apps/website/app/races/all/page.tsx
@@ -7,8 +7,8 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Heading from '@/components/ui/Heading';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
-import { apiClient } from '@/lib/apiClient';
-import type { RacesPageDataViewModel, RacesPageDataRaceViewModel } from '@/lib/apiClient';
+import { useServices } from '@/lib/services/ServiceProvider';
+import { RacesPageViewModel } from '@/lib/view-models/RacesPageViewModel';
import {
Calendar,
Clock,
@@ -34,8 +34,9 @@ type StatusFilter = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'all';
export default function AllRacesPage() {
const router = useRouter();
const searchParams = useSearchParams();
+ const { raceService } = useServices();
- const [pageData, setPageData] = useState
(null);
+ const [pageData, setPageData] = useState(null);
const [loading, setLoading] = useState(true);
// Pagination
@@ -49,7 +50,7 @@ export default function AllRacesPage() {
const loadRaces = async () => {
try {
- const viewModel = await apiClient.races.getAllPageData();
+ const viewModel = await raceService.getAllRacesPageData();
setPageData(viewModel);
} catch (err) {
console.error('Failed to load races:', err);
@@ -62,7 +63,7 @@ export default function AllRacesPage() {
void loadRaces();
}, []);
- const races: RacesPageDataRaceViewModel[] = pageData?.races ?? [];
+ const races = pageData?.races ?? [];
const filteredRaces = useMemo(() => {
return races.filter(race => {
@@ -284,8 +285,8 @@ export default function AllRacesPage() {
) : (
{paginatedRaces.map(race => {
- const config = statusConfig[race.status];
- const StatusIcon = config.icon;
+ const config = statusConfig[race.status as keyof typeof statusConfig];
+ const StatusIcon = config.icon;
return (
(null);
+ const [pageData, setPageData] = useState
(null);
const [loading, setLoading] = useState(true);
// Filters
@@ -42,7 +43,7 @@ export default function RacesPage() {
const loadRaces = async () => {
try {
- const data = await apiClient.races.getPageData();
+ const data = await raceService.getRacesPageData();
setPageData(data);
} catch (err) {
console.error('Failed to load races:', err);
@@ -87,11 +88,8 @@ export default function RacesPage() {
// Group races by date for calendar view
const racesByDate = useMemo(() => {
- const grouped = new Map();
+ const grouped = new Map();
filteredRaces.forEach((race) => {
- if (typeof race.scheduledAt !== 'string') {
- return;
- }
const dateKey = race.scheduledAt.split('T')[0]!;
if (!grouped.has(dateKey)) {
grouped.set(dateKey, []);
@@ -105,10 +103,10 @@ export default function RacesPage() {
const liveRaces = filteredRaces.filter(r => r.isLive);
const recentResults = filteredRaces.filter(r => r.isPast).slice(0, 5);
const stats = {
- total: pageData?.races.length ?? 0,
- scheduled: pageData?.races.filter(r => r.status === 'scheduled').length ?? 0,
- running: pageData?.races.filter(r => r.status === 'running').length ?? 0,
- completed: pageData?.races.filter(r => r.status === 'completed').length ?? 0,
+ total: pageData?.totalCount ?? 0,
+ scheduled: pageData?.scheduledRaces.length ?? 0,
+ running: pageData?.runningRaces.length ?? 0,
+ completed: pageData?.completedRaces.length ?? 0,
};
const formatDate = (date: Date | string) => {
@@ -348,7 +346,7 @@ export default function RacesPage() {
No races found
- {pageData?.races.length === 0
+ {pageData?.totalCount === 0
? 'No races have been scheduled yet'
: 'Try adjusting your filters'}
@@ -375,10 +373,7 @@ export default function RacesPage() {
{/* Races for this date */}
{dayRaces.map((race) => {
- if (!race.scheduledAt) {
- return null;
- }
- const config = statusConfig[race.status];
+ const config = statusConfig[race.status as keyof typeof statusConfig];
const StatusIcon = config.icon;
return (
diff --git a/apps/website/lib/api/wallets/WalletsApiClient.ts b/apps/website/lib/api/wallets/WalletsApiClient.ts
new file mode 100644
index 000000000..f9097379a
--- /dev/null
+++ b/apps/website/lib/api/wallets/WalletsApiClient.ts
@@ -0,0 +1,54 @@
+import { BaseApiClient } from '../base/BaseApiClient';
+
+export interface LeagueWalletDTO {
+ balance: number;
+ currency: string;
+ totalRevenue: number;
+ totalFees: number;
+ totalWithdrawals: number;
+ pendingPayouts: number;
+ canWithdraw: boolean;
+ withdrawalBlockReason?: string;
+ transactions: WalletTransactionDTO[];
+}
+
+export interface WalletTransactionDTO {
+ id: string;
+ type: 'sponsorship' | 'membership' | 'withdrawal' | 'prize';
+ description: string;
+ amount: number;
+ fee: number;
+ netAmount: number;
+ date: string; // ISO string
+ status: 'completed' | 'pending' | 'failed';
+ reference?: string;
+}
+
+export interface WithdrawRequestDTO {
+ amount: number;
+ currency: string;
+ seasonId: string;
+ destinationAccount: string;
+}
+
+export interface WithdrawResponseDTO {
+ success: boolean;
+ message?: string;
+}
+
+/**
+ * Wallets API Client
+ *
+ * Handles all wallet-related API operations.
+ */
+export class WalletsApiClient extends BaseApiClient {
+ /** Get league wallet */
+ getLeagueWallet(leagueId: string): Promise {
+ return this.get(`/leagues/${leagueId}/wallet`);
+ }
+
+ /** Withdraw from league wallet */
+ withdrawFromLeagueWallet(leagueId: string, request: WithdrawRequestDTO): Promise {
+ return this.post(`/leagues/${leagueId}/wallet/withdraw`, request);
+ }
+}
\ No newline at end of file
diff --git a/apps/website/lib/services/ServiceFactory.ts b/apps/website/lib/services/ServiceFactory.ts
index 49c28c938..4c10f719e 100644
--- a/apps/website/lib/services/ServiceFactory.ts
+++ b/apps/website/lib/services/ServiceFactory.ts
@@ -4,6 +4,7 @@ import { TeamsApiClient } from '../api/teams/TeamsApiClient';
import { LeaguesApiClient } from '../api/leagues/LeaguesApiClient';
import { SponsorsApiClient } from '../api/sponsors/SponsorsApiClient';
import { PaymentsApiClient } from '../api/payments/PaymentsApiClient';
+import { WalletsApiClient } from '../api/wallets/WalletsApiClient';
import { AuthApiClient } from '../api/auth/AuthApiClient';
import { AnalyticsApiClient } from '../api/analytics/AnalyticsApiClient';
import { MediaApiClient } from '../api/media/MediaApiClient';
@@ -17,6 +18,7 @@ import { ConsoleLogger } from '../infrastructure/logging/ConsoleLogger';
// Services
import { RaceService } from './races/RaceService';
import { RaceResultsService } from './races/RaceResultsService';
+import { RaceStewardingService } from './races/RaceStewardingService';
import { DriverService } from './drivers/DriverService';
import { DriverRegistrationService } from './drivers/DriverRegistrationService';
import { TeamService } from './teams/TeamService';
@@ -24,6 +26,8 @@ import { TeamJoinService } from './teams/TeamJoinService';
import { LeagueService } from './leagues/LeagueService';
import { LeagueMembershipService } from './leagues/LeagueMembershipService';
import { LeagueSettingsService } from './leagues/LeagueSettingsService';
+import { LeagueStewardingService } from './leagues/LeagueStewardingService';
+import { LeagueWalletService } from './leagues/LeagueWalletService';
import { SponsorService } from './sponsors/SponsorService';
import { SponsorshipService } from './sponsors/SponsorshipService';
import { PaymentService } from './payments/PaymentService';
@@ -56,6 +60,7 @@ export class ServiceFactory {
leagues: LeaguesApiClient;
sponsors: SponsorsApiClient;
payments: PaymentsApiClient;
+ wallets: WalletsApiClient;
auth: AuthApiClient;
analytics: AnalyticsApiClient;
media: MediaApiClient;
@@ -73,6 +78,7 @@ export class ServiceFactory {
leagues: new LeaguesApiClient(baseUrl, this.errorReporter, this.logger),
sponsors: new SponsorsApiClient(baseUrl, this.errorReporter, this.logger),
payments: new PaymentsApiClient(baseUrl, this.errorReporter, this.logger),
+ wallets: new WalletsApiClient(baseUrl, this.errorReporter, this.logger),
auth: new AuthApiClient(baseUrl, this.errorReporter, this.logger),
analytics: new AnalyticsApiClient(baseUrl, this.errorReporter, this.logger),
media: new MediaApiClient(baseUrl, this.errorReporter, this.logger),
@@ -96,6 +102,17 @@ export class ServiceFactory {
return new RaceResultsService(this.apiClients.races);
}
+ /**
+ * Create RaceStewardingService instance
+ */
+ createRaceStewardingService(): RaceStewardingService {
+ return new RaceStewardingService(
+ this.apiClients.races,
+ this.apiClients.protests,
+ this.apiClients.penalties
+ );
+ }
+
/**
* Create DriverService instance
*/
@@ -145,6 +162,26 @@ export class ServiceFactory {
return new LeagueSettingsService(this.apiClients.leagues, this.apiClients.drivers);
}
+ /**
+ * Create LeagueStewardingService instance
+ */
+ createLeagueStewardingService(): LeagueStewardingService {
+ return new LeagueStewardingService(
+ this.createRaceService(),
+ this.createProtestService(),
+ this.createPenaltyService(),
+ this.createDriverService(),
+ this.createLeagueMembershipService()
+ );
+ }
+
+ /**
+ * Create LeagueWalletService instance
+ */
+ createLeagueWalletService(): LeagueWalletService {
+ return new LeagueWalletService(this.apiClients.wallets);
+ }
+
/**
* Create SponsorService instance
*/
diff --git a/apps/website/lib/services/ServiceProvider.tsx b/apps/website/lib/services/ServiceProvider.tsx
index bea838148..765e3f45a 100644
--- a/apps/website/lib/services/ServiceProvider.tsx
+++ b/apps/website/lib/services/ServiceProvider.tsx
@@ -6,6 +6,7 @@ import { ServiceFactory } from './ServiceFactory';
// Import all service types
import { RaceService } from './races/RaceService';
import { RaceResultsService } from './races/RaceResultsService';
+import { RaceStewardingService } from './races/RaceStewardingService';
import { DriverService } from './drivers/DriverService';
import { DriverRegistrationService } from './drivers/DriverRegistrationService';
import { TeamService } from './teams/TeamService';
@@ -13,6 +14,8 @@ import { TeamJoinService } from './teams/TeamJoinService';
import { LeagueService } from './leagues/LeagueService';
import { LeagueMembershipService } from './leagues/LeagueMembershipService';
import { LeagueSettingsService } from './leagues/LeagueSettingsService';
+import { LeagueStewardingService } from './leagues/LeagueStewardingService';
+import { LeagueWalletService } from './leagues/LeagueWalletService';
import { SponsorService } from './sponsors/SponsorService';
import { SponsorshipService } from './sponsors/SponsorshipService';
import { PaymentService } from './payments/PaymentService';
@@ -30,6 +33,7 @@ import { PenaltyService } from './penalties/PenaltyService';
export interface Services {
raceService: RaceService;
raceResultsService: RaceResultsService;
+ raceStewardingService: RaceStewardingService;
driverService: DriverService;
driverRegistrationService: DriverRegistrationService;
teamService: TeamService;
@@ -37,6 +41,8 @@ export interface Services {
leagueService: LeagueService;
leagueMembershipService: LeagueMembershipService;
leagueSettingsService: LeagueSettingsService;
+ leagueStewardingService: LeagueStewardingService;
+ leagueWalletService: LeagueWalletService;
sponsorService: SponsorService;
sponsorshipService: SponsorshipService;
paymentService: PaymentService;
@@ -65,6 +71,7 @@ export function ServiceProvider({ children }: ServiceProviderProps) {
return {
raceService: serviceFactory.createRaceService(),
raceResultsService: serviceFactory.createRaceResultsService(),
+ raceStewardingService: serviceFactory.createRaceStewardingService(),
driverService: serviceFactory.createDriverService(),
driverRegistrationService: serviceFactory.createDriverRegistrationService(),
teamService: serviceFactory.createTeamService(),
@@ -72,6 +79,8 @@ export function ServiceProvider({ children }: ServiceProviderProps) {
leagueService: serviceFactory.createLeagueService(),
leagueMembershipService: serviceFactory.createLeagueMembershipService(),
leagueSettingsService: serviceFactory.createLeagueSettingsService(),
+ leagueStewardingService: serviceFactory.createLeagueStewardingService(),
+ leagueWalletService: serviceFactory.createLeagueWalletService(),
sponsorService: serviceFactory.createSponsorService(),
sponsorshipService: serviceFactory.createSponsorshipService(),
paymentService: serviceFactory.createPaymentService(),
diff --git a/apps/website/lib/services/leagues/LeagueStewardingService.ts b/apps/website/lib/services/leagues/LeagueStewardingService.ts
new file mode 100644
index 000000000..e552e09e0
--- /dev/null
+++ b/apps/website/lib/services/leagues/LeagueStewardingService.ts
@@ -0,0 +1,93 @@
+import { RaceService } from '../races/RaceService';
+import { ProtestService } from '../protests/ProtestService';
+import { PenaltyService } from '../penalties/PenaltyService';
+import { DriverService } from '../drivers/DriverService';
+import { LeagueMembershipService } from './LeagueMembershipService';
+import { LeagueStewardingViewModel, RaceWithProtests } from '../../view-models/LeagueStewardingViewModel';
+
+/**
+ * League Stewarding Service
+ *
+ * Orchestrates league stewarding operations by coordinating calls to race, protest, penalty, driver, and membership services.
+ * All dependencies are injected via constructor.
+ */
+export class LeagueStewardingService {
+ constructor(
+ private readonly raceService: RaceService,
+ private readonly protestService: ProtestService,
+ private readonly penaltyService: PenaltyService,
+ private readonly driverService: DriverService,
+ private readonly leagueMembershipService: LeagueMembershipService
+ ) {}
+
+ /**
+ * Get league stewarding data for all races in a league
+ */
+ async getLeagueStewardingData(leagueId: string): Promise {
+ // Get all races for this league
+ const leagueRaces = await this.raceService.findByLeagueId(leagueId);
+
+ // Get protests and penalties for each race
+ const protestsMap: Record = {};
+ const penaltiesMap: Record = {};
+ const driverIds = new Set();
+
+ for (const race of leagueRaces) {
+ const raceProtests = await this.protestService.findByRaceId(race.id);
+ const racePenalties = await this.penaltyService.findByRaceId(race.id);
+
+ protestsMap[race.id] = raceProtests;
+ penaltiesMap[race.id] = racePenalties;
+
+ // Collect driver IDs
+ raceProtests.forEach((p: any) => {
+ driverIds.add(p.protestingDriverId);
+ driverIds.add(p.accusedDriverId);
+ });
+ racePenalties.forEach((p: any) => {
+ driverIds.add(p.driverId);
+ });
+ }
+
+ // Load driver info
+ const driverEntities = await this.driverService.findByIds(Array.from(driverIds));
+ const driverMap: Record = {};
+ driverEntities.forEach((driver) => {
+ if (driver) {
+ driverMap[driver.id] = driver;
+ }
+ });
+
+ // Compute race data with protest/penalty info
+ const racesWithData: RaceWithProtests[] = leagueRaces.map(race => {
+ const protests = protestsMap[race.id] || [];
+ const penalties = penaltiesMap[race.id] || [];
+ return {
+ race: {
+ id: race.id,
+ track: race.track,
+ scheduledAt: new Date(race.scheduledAt),
+ },
+ pendingProtests: protests.filter(p => p.status === 'pending' || p.status === 'under_review'),
+ resolvedProtests: protests.filter(p => p.status === 'upheld' || p.status === 'dismissed' || p.status === 'withdrawn'),
+ penalties
+ };
+ }).sort((a, b) => b.race.scheduledAt.getTime() - a.race.scheduledAt.getTime());
+
+ return new LeagueStewardingViewModel(racesWithData, driverMap);
+ }
+
+ /**
+ * Review a protest
+ */
+ async reviewProtest(input: { protestId: string; stewardId: string; decision: string; decisionNotes: string }): Promise {
+ await this.protestService.reviewProtest(input);
+ }
+
+ /**
+ * Apply a penalty
+ */
+ async applyPenalty(input: any): Promise {
+ await this.penaltyService.applyPenalty(input);
+ }
+}
\ No newline at end of file
diff --git a/apps/website/lib/services/leagues/LeagueWalletService.ts b/apps/website/lib/services/leagues/LeagueWalletService.ts
new file mode 100644
index 000000000..79e9e8266
--- /dev/null
+++ b/apps/website/lib/services/leagues/LeagueWalletService.ts
@@ -0,0 +1,71 @@
+import { WalletsApiClient, LeagueWalletDTO, WithdrawRequestDTO, WithdrawResponseDTO } from '@/lib/api/wallets/WalletsApiClient';
+import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel';
+import { WalletTransactionViewModel } from '@/lib/view-models/WalletTransactionViewModel';
+import { SubmitBlocker, ThrottleBlocker } from '@/lib/blockers';
+
+/**
+ * League Wallet Service
+ *
+ * Orchestrates league wallet operations by coordinating API calls and view model creation.
+ * All dependencies are injected via constructor.
+ */
+export class LeagueWalletService {
+ private readonly submitBlocker = new SubmitBlocker();
+ private readonly throttle = new ThrottleBlocker(500);
+
+ constructor(
+ private readonly apiClient: WalletsApiClient
+ ) {}
+
+ /**
+ * Get wallet for a league
+ */
+ async getWalletForLeague(leagueId: string): Promise {
+ const dto = await this.apiClient.getLeagueWallet(leagueId);
+ const transactions = dto.transactions.map(t => new WalletTransactionViewModel({
+ id: t.id,
+ type: t.type,
+ description: t.description,
+ amount: t.amount,
+ fee: t.fee,
+ netAmount: t.netAmount,
+ date: new Date(t.date),
+ status: t.status,
+ reference: t.reference,
+ }));
+ return new LeagueWalletViewModel({
+ balance: dto.balance,
+ currency: dto.currency,
+ totalRevenue: dto.totalRevenue,
+ totalFees: dto.totalFees,
+ totalWithdrawals: dto.totalWithdrawals,
+ pendingPayouts: dto.pendingPayouts,
+ transactions,
+ canWithdraw: dto.canWithdraw,
+ withdrawalBlockReason: dto.withdrawalBlockReason,
+ });
+ }
+
+ /**
+ * Withdraw from league wallet
+ */
+ async withdraw(leagueId: string, amount: number, currency: string, seasonId: string, destinationAccount: string): Promise {
+ if (!this.submitBlocker.canExecute() || !this.throttle.canExecute()) {
+ throw new Error('Request blocked due to rate limiting');
+ }
+
+ this.submitBlocker.block();
+ this.throttle.block();
+ try {
+ const request: WithdrawRequestDTO = {
+ amount,
+ currency,
+ seasonId,
+ destinationAccount,
+ };
+ return await this.apiClient.withdrawFromLeagueWallet(leagueId, request);
+ } finally {
+ this.submitBlocker.release();
+ }
+ }
+}
\ No newline at end of file
diff --git a/apps/website/lib/services/races/RaceService.ts b/apps/website/lib/services/races/RaceService.ts
index bb6ef7724..bd5d5131f 100644
--- a/apps/website/lib/services/races/RaceService.ts
+++ b/apps/website/lib/services/races/RaceService.ts
@@ -43,7 +43,16 @@ export class RaceService {
*/
async getRacesPageData(): Promise {
const dto = await this.apiClient.getPageData();
- return new RacesPageViewModel(this.transformRacesPageData(dto));
+ return new RacesPageViewModel(dto);
+ }
+
+ /**
+ * Get all races page data with view model transformation
+ * Currently same as getRacesPageData, but can be extended for different filtering
+ */
+ async getAllRacesPageData(): Promise {
+ const dto = await this.apiClient.getPageData();
+ return new RacesPageViewModel(dto);
}
/**
@@ -82,38 +91,6 @@ export class RaceService {
await this.apiClient.complete(raceId);
}
- /**
- * Transform API races page data to view model format
- */
- private transformRacesPageData(dto: RacesPageDataDto): {
- upcomingRaces: Array<{ id: string; title: string; scheduledTime: string; status: string }>;
- completedRaces: Array<{ id: string; title: string; scheduledTime: string; status: string }>;
- totalCount: number;
- } {
- const upcomingRaces = dto.races
- .filter(race => race.status !== 'completed')
- .map(race => ({
- id: race.id,
- title: `${race.track} - ${race.car}`,
- scheduledTime: race.scheduledAt,
- status: race.status,
- }));
-
- const completedRaces = dto.races
- .filter(race => race.status === 'completed')
- .map(race => ({
- id: race.id,
- title: `${race.track} - ${race.car}`,
- scheduledTime: race.scheduledAt,
- status: race.status,
- }));
-
- return {
- upcomingRaces,
- completedRaces,
- totalCount: dto.races.length,
- };
- }
/**
* Find races by league ID
diff --git a/apps/website/lib/services/races/RaceStewardingService.ts b/apps/website/lib/services/races/RaceStewardingService.ts
new file mode 100644
index 000000000..fc8747455
--- /dev/null
+++ b/apps/website/lib/services/races/RaceStewardingService.ts
@@ -0,0 +1,36 @@
+import { RacesApiClient } from '../../api/races/RacesApiClient';
+import { ProtestsApiClient } from '../../api/protests/ProtestsApiClient';
+import { PenaltiesApiClient } from '../../api/penalties/PenaltiesApiClient';
+import { RaceStewardingViewModel } from '../../view-models/RaceStewardingViewModel';
+
+/**
+ * Race Stewarding Service
+ *
+ * Orchestrates race stewarding operations by coordinating API calls for race details,
+ * protests, and penalties, and returning a unified view model.
+ */
+export class RaceStewardingService {
+ constructor(
+ private readonly racesApiClient: RacesApiClient,
+ private readonly protestsApiClient: ProtestsApiClient,
+ private readonly penaltiesApiClient: PenaltiesApiClient
+ ) {}
+
+ /**
+ * Get race stewarding data with view model transformation
+ */
+ async getRaceStewardingData(raceId: string, driverId: string): Promise {
+ // Fetch all data in parallel
+ const [raceDetail, protests, penalties] = await Promise.all([
+ this.racesApiClient.getDetail(raceId, driverId),
+ this.protestsApiClient.getRaceProtests(raceId),
+ this.penaltiesApiClient.getRacePenalties(raceId),
+ ]);
+
+ return new RaceStewardingViewModel({
+ raceDetail,
+ protests,
+ penalties,
+ });
+ }
+}
\ No newline at end of file
diff --git a/apps/website/lib/view-models/LeagueStewardingViewModel.ts b/apps/website/lib/view-models/LeagueStewardingViewModel.ts
new file mode 100644
index 000000000..107510a58
--- /dev/null
+++ b/apps/website/lib/view-models/LeagueStewardingViewModel.ts
@@ -0,0 +1,74 @@
+/**
+ * League Stewarding View Model
+ * Represents all data needed for league stewarding across all races
+ */
+export class LeagueStewardingViewModel {
+ constructor(
+ public readonly racesWithData: RaceWithProtests[],
+ public readonly driverMap: Record
+ ) {}
+
+ /** UI-specific: Total pending protests count */
+ get totalPending(): number {
+ return this.racesWithData.reduce((sum, r) => sum + r.pendingProtests.length, 0);
+ }
+
+ /** UI-specific: Total resolved protests count */
+ get totalResolved(): number {
+ return this.racesWithData.reduce((sum, r) => sum + r.resolvedProtests.length, 0);
+ }
+
+ /** UI-specific: Total penalties count */
+ get totalPenalties(): number {
+ return this.racesWithData.reduce((sum, r) => sum + r.penalties.length, 0);
+ }
+
+ /** UI-specific: Filtered races for pending tab */
+ get pendingRaces(): RaceWithProtests[] {
+ return this.racesWithData.filter(r => r.pendingProtests.length > 0);
+ }
+
+ /** UI-specific: Filtered races for history tab */
+ get historyRaces(): RaceWithProtests[] {
+ return this.racesWithData.filter(r => r.resolvedProtests.length > 0 || r.penalties.length > 0);
+ }
+
+ /** UI-specific: All drivers for quick penalty modal */
+ get allDrivers(): Array<{ id: string; name: string; avatarUrl?: string; iracingId?: string; rating?: number }> {
+ return Object.values(this.driverMap);
+ }
+}
+
+export interface RaceWithProtests {
+ race: {
+ id: string;
+ track: string;
+ scheduledAt: Date;
+ };
+ pendingProtests: Protest[];
+ resolvedProtests: Protest[];
+ penalties: Penalty[];
+}
+
+export interface Protest {
+ id: string;
+ protestingDriverId: string;
+ accusedDriverId: string;
+ incident: {
+ lap: number;
+ description: string;
+ };
+ filedAt: string;
+ status: string;
+ decisionNotes?: string;
+ proofVideoUrl?: string;
+}
+
+export interface Penalty {
+ id: string;
+ driverId: string;
+ type: string;
+ value: number;
+ reason: string;
+ notes?: string;
+}
\ No newline at end of file
diff --git a/apps/website/lib/view-models/LeagueWalletViewModel.ts b/apps/website/lib/view-models/LeagueWalletViewModel.ts
new file mode 100644
index 000000000..ca2241802
--- /dev/null
+++ b/apps/website/lib/view-models/LeagueWalletViewModel.ts
@@ -0,0 +1,60 @@
+import { WalletTransactionViewModel } from './WalletTransactionViewModel';
+
+export class LeagueWalletViewModel {
+ balance: number;
+ currency: string;
+ totalRevenue: number;
+ totalFees: number;
+ totalWithdrawals: number;
+ pendingPayouts: number;
+ transactions: WalletTransactionViewModel[];
+ canWithdraw: boolean;
+ withdrawalBlockReason?: string;
+
+ constructor(dto: {
+ balance: number;
+ currency: string;
+ totalRevenue: number;
+ totalFees: number;
+ totalWithdrawals: number;
+ pendingPayouts: number;
+ transactions: WalletTransactionViewModel[];
+ canWithdraw: boolean;
+ withdrawalBlockReason?: string;
+ }) {
+ this.balance = dto.balance;
+ this.currency = dto.currency;
+ this.totalRevenue = dto.totalRevenue;
+ this.totalFees = dto.totalFees;
+ this.totalWithdrawals = dto.totalWithdrawals;
+ this.pendingPayouts = dto.pendingPayouts;
+ this.transactions = dto.transactions;
+ this.canWithdraw = dto.canWithdraw;
+ this.withdrawalBlockReason = dto.withdrawalBlockReason;
+ }
+
+ /** UI-specific: Formatted balance */
+ get formattedBalance(): string {
+ return `$${this.balance.toFixed(2)}`;
+ }
+
+ /** UI-specific: Formatted total revenue */
+ get formattedTotalRevenue(): string {
+ return `$${this.totalRevenue.toFixed(2)}`;
+ }
+
+ /** UI-specific: Formatted total fees */
+ get formattedTotalFees(): string {
+ return `$${this.totalFees.toFixed(2)}`;
+ }
+
+ /** UI-specific: Formatted pending payouts */
+ get formattedPendingPayouts(): string {
+ return `$${this.pendingPayouts.toFixed(2)}`;
+ }
+
+ /** UI-specific: Filtered transactions by type */
+ getFilteredTransactions(type: 'all' | 'sponsorship' | 'membership' | 'withdrawal' | 'prize'): WalletTransactionViewModel[] {
+ return type === 'all' ? this.transactions : this.transactions.filter(t => t.type === type);
+ }
+}
\ No newline at end of file
diff --git a/apps/website/lib/view-models/RaceListItemViewModel.ts b/apps/website/lib/view-models/RaceListItemViewModel.ts
index 88d11d03d..44d806cd1 100644
--- a/apps/website/lib/view-models/RaceListItemViewModel.ts
+++ b/apps/website/lib/view-models/RaceListItemViewModel.ts
@@ -1,62 +1,65 @@
-// Note: No generated DTO available for RaceListItem yet
+// DTO matching the backend RacesPageDataRaceDTO
interface RaceListItemDTO {
id: string;
- name: string;
+ track: string;
+ car: string;
+ scheduledAt: string;
+ status: string;
leagueId: string;
leagueName: string;
- scheduledTime: string;
- status: string;
- trackName?: string;
+ strengthOfField: number | null;
+ isUpcoming: boolean;
+ isLive: boolean;
+ isPast: boolean;
}
export class RaceListItemViewModel {
id: string;
- name: string;
+ track: string;
+ car: string;
+ scheduledAt: string;
+ status: string;
leagueId: string;
leagueName: string;
- scheduledTime: string;
- status: string;
- trackName?: string;
+ strengthOfField: number | null;
+ isUpcoming: boolean;
+ isLive: boolean;
+ isPast: boolean;
constructor(dto: RaceListItemDTO) {
this.id = dto.id;
- this.name = dto.name;
+ this.track = dto.track;
+ this.car = dto.car;
+ this.scheduledAt = dto.scheduledAt;
+ this.status = dto.status;
this.leagueId = dto.leagueId;
this.leagueName = dto.leagueName;
- this.scheduledTime = dto.scheduledTime;
- this.status = dto.status;
- if (dto.trackName !== undefined) this.trackName = dto.trackName;
+ this.strengthOfField = dto.strengthOfField;
+ this.isUpcoming = dto.isUpcoming;
+ this.isLive = dto.isLive;
+ this.isPast = dto.isPast;
}
/** UI-specific: Formatted scheduled time */
get formattedScheduledTime(): string {
- return new Date(this.scheduledTime).toLocaleString();
+ return new Date(this.scheduledAt).toLocaleString();
}
/** UI-specific: Badge variant for status */
get statusBadgeVariant(): string {
switch (this.status) {
- case 'upcoming': return 'info';
- case 'live': return 'success';
- case 'finished': return 'secondary';
+ case 'scheduled': return 'info';
+ case 'running': return 'success';
+ case 'completed': return 'secondary';
+ case 'cancelled': return 'danger';
default: return 'default';
}
}
- /** UI-specific: Whether race is upcoming */
- get isUpcoming(): boolean {
- return this.status === 'upcoming';
- }
-
- /** UI-specific: Whether race is live */
- get isLive(): boolean {
- return this.status === 'live';
- }
-
/** UI-specific: Time until start in minutes */
get timeUntilStart(): number {
const now = new Date();
- const scheduled = new Date(this.scheduledTime);
+ const scheduled = new Date(this.scheduledAt);
return Math.max(0, Math.floor((scheduled.getTime() - now.getTime()) / (1000 * 60)));
}
diff --git a/apps/website/lib/view-models/RaceStewardingViewModel.ts b/apps/website/lib/view-models/RaceStewardingViewModel.ts
new file mode 100644
index 000000000..00365693e
--- /dev/null
+++ b/apps/website/lib/view-models/RaceStewardingViewModel.ts
@@ -0,0 +1,99 @@
+// DTO interfaces matching the API responses
+interface RaceDetailDTO {
+ race: {
+ id: string;
+ track: string;
+ scheduledAt: string;
+ status: string;
+ } | null;
+ league: {
+ id: string;
+ name: string;
+ } | null;
+}
+
+interface RaceProtestsDTO {
+ protests: Array<{
+ id: string;
+ protestingDriverId: string;
+ accusedDriverId: string;
+ incident: {
+ lap: number;
+ description: string;
+ };
+ filedAt: string;
+ status: string;
+ decisionNotes?: string;
+ proofVideoUrl?: string;
+ }>;
+ driverMap: Record;
+}
+
+interface RacePenaltiesDTO {
+ penalties: Array<{
+ id: string;
+ driverId: string;
+ type: string;
+ value: number;
+ reason: string;
+ notes?: string;
+ }>;
+ driverMap: Record;
+}
+
+interface RaceStewardingDTO {
+ raceDetail: RaceDetailDTO;
+ protests: RaceProtestsDTO;
+ penalties: RacePenaltiesDTO;
+}
+
+/**
+ * Race Stewarding View Model
+ * Represents all data needed for race stewarding (protests, penalties, race info)
+ */
+export class RaceStewardingViewModel {
+ race: RaceDetailDTO['race'];
+ league: RaceDetailDTO['league'];
+ protests: RaceProtestsDTO['protests'];
+ penalties: RacePenaltiesDTO['penalties'];
+ driverMap: Record;
+
+ constructor(dto: RaceStewardingDTO) {
+ this.race = dto.raceDetail.race;
+ this.league = dto.raceDetail.league;
+ this.protests = dto.protests.protests;
+ this.penalties = dto.penalties.penalties;
+
+ // Merge driver maps from protests and penalties
+ this.driverMap = { ...dto.protests.driverMap, ...dto.penalties.driverMap };
+ }
+
+ /** UI-specific: Pending protests */
+ get pendingProtests() {
+ return this.protests.filter(p => p.status === 'pending' || p.status === 'under_review');
+ }
+
+ /** UI-specific: Resolved protests */
+ get resolvedProtests() {
+ return this.protests.filter(p =>
+ p.status === 'upheld' ||
+ p.status === 'dismissed' ||
+ p.status === 'withdrawn'
+ );
+ }
+
+ /** UI-specific: Total pending protests count */
+ get pendingCount(): number {
+ return this.pendingProtests.length;
+ }
+
+ /** UI-specific: Total resolved protests count */
+ get resolvedCount(): number {
+ return this.resolvedProtests.length;
+ }
+
+ /** UI-specific: Total penalties count */
+ get penaltiesCount(): number {
+ return this.penalties.length;
+ }
+}
\ No newline at end of file
diff --git a/apps/website/lib/view-models/RacesPageViewModel.ts b/apps/website/lib/view-models/RacesPageViewModel.ts
index bb2e934b2..f9e8c4db0 100644
--- a/apps/website/lib/view-models/RacesPageViewModel.ts
+++ b/apps/website/lib/view-models/RacesPageViewModel.ts
@@ -1,63 +1,65 @@
-// Note: No generated DTO available for RaceCard yet
-interface RaceCardDTO {
- id: string;
- title: string;
- scheduledTime: string;
- status: string;
-}
+import { RaceListItemViewModel } from './RaceListItemViewModel';
-/**
- * Race card view model
- * Represents a race card in list views
- */
-export class RaceCardViewModel {
- id: string;
- title: string;
- scheduledTime: string;
- status: string;
-
- constructor(dto: RaceCardDTO) {
- this.id = dto.id;
- this.title = dto.title;
- this.scheduledTime = dto.scheduledTime;
- this.status = dto.status;
- }
-
- /** UI-specific: Formatted scheduled time */
- get formattedScheduledTime(): string {
- return new Date(this.scheduledTime).toLocaleString();
- }
-}
-
-// Note: No generated DTO available for RacesPage yet
+// DTO matching the backend RacesPageDataDTO
interface RacesPageDTO {
- upcomingRaces: RaceCardDTO[];
- completedRaces: RaceCardDTO[];
- totalCount: number;
+ races: Array<{
+ id: string;
+ track: string;
+ car: string;
+ scheduledAt: string;
+ status: string;
+ leagueId: string;
+ leagueName: string;
+ strengthOfField: number | null;
+ isUpcoming: boolean;
+ isLive: boolean;
+ isPast: boolean;
+ }>;
}
/**
* Races page view model
- * Represents the races page data
+ * Represents the races page data with all races in a single list
*/
export class RacesPageViewModel {
- upcomingRaces: RaceCardViewModel[];
- completedRaces: RaceCardViewModel[];
- totalCount: number;
+ races: RaceListItemViewModel[];
constructor(dto: RacesPageDTO) {
- this.upcomingRaces = dto.upcomingRaces.map(r => new RaceCardViewModel(r));
- this.completedRaces = dto.completedRaces.map(r => new RaceCardViewModel(r));
- this.totalCount = dto.totalCount;
+ this.races = dto.races.map(r => new RaceListItemViewModel(r));
}
- /** UI-specific: Total upcoming races */
- get upcomingCount(): number {
- return this.upcomingRaces.length;
+ /** UI-specific: Total races */
+ get totalCount(): number {
+ return this.races.length;
}
- /** UI-specific: Total completed races */
- get completedCount(): number {
- return this.completedRaces.length;
+ /** UI-specific: Upcoming races */
+ get upcomingRaces(): RaceListItemViewModel[] {
+ return this.races.filter(r => r.isUpcoming);
+ }
+
+ /** UI-specific: Live races */
+ get liveRaces(): RaceListItemViewModel[] {
+ return this.races.filter(r => r.isLive);
+ }
+
+ /** UI-specific: Past races */
+ get pastRaces(): RaceListItemViewModel[] {
+ return this.races.filter(r => r.isPast);
+ }
+
+ /** UI-specific: Scheduled races */
+ get scheduledRaces(): RaceListItemViewModel[] {
+ return this.races.filter(r => r.status === 'scheduled');
+ }
+
+ /** UI-specific: Running races */
+ get runningRaces(): RaceListItemViewModel[] {
+ return this.races.filter(r => r.status === 'running');
+ }
+
+ /** UI-specific: Completed races */
+ get completedRaces(): RaceListItemViewModel[] {
+ return this.races.filter(r => r.status === 'completed');
}
}
\ No newline at end of file
diff --git a/apps/website/lib/view-models/WalletTransactionViewModel.ts b/apps/website/lib/view-models/WalletTransactionViewModel.ts
index 4d3ac31c2..485f6f377 100644
--- a/apps/website/lib/view-models/WalletTransactionViewModel.ts
+++ b/apps/website/lib/view-models/WalletTransactionViewModel.ts
@@ -1,39 +1,45 @@
-import { TransactionDto } from '../types/generated/TransactionDto';
-
-// TODO: Use generated TransactionDto when it includes all required fields
-export type FullTransactionDto = TransactionDto & {
- amount: number;
- description: string;
- createdAt: string;
- type: 'deposit' | 'withdrawal';
-};
-
export class WalletTransactionViewModel {
id: string;
- walletId: string;
- amount: number;
+ type: 'sponsorship' | 'membership' | 'withdrawal' | 'prize';
description: string;
- createdAt: string;
- type: 'deposit' | 'withdrawal';
+ amount: number;
+ fee: number;
+ netAmount: number;
+ date: Date;
+ status: 'completed' | 'pending' | 'failed';
+ reference?: string;
- constructor(dto: FullTransactionDto) {
+ constructor(dto: {
+ id: string;
+ type: 'sponsorship' | 'membership' | 'withdrawal' | 'prize';
+ description: string;
+ amount: number;
+ fee: number;
+ netAmount: number;
+ date: Date;
+ status: 'completed' | 'pending' | 'failed';
+ reference?: string;
+ }) {
this.id = dto.id;
- this.walletId = dto.walletId;
- this.amount = dto.amount;
- this.description = dto.description;
- this.createdAt = dto.createdAt;
this.type = dto.type;
+ this.description = dto.description;
+ this.amount = dto.amount;
+ this.fee = dto.fee;
+ this.netAmount = dto.netAmount;
+ this.date = dto.date;
+ this.status = dto.status;
+ this.reference = dto.reference;
}
/** UI-specific: Formatted amount with sign */
get formattedAmount(): string {
- const sign = this.type === 'deposit' ? '+' : '-';
- return `${sign}$${this.amount.toFixed(2)}`;
+ const sign = this.amount > 0 ? '+' : '';
+ return `${sign}$${Math.abs(this.amount).toFixed(2)}`;
}
/** UI-specific: Amount color */
get amountColor(): string {
- return this.type === 'deposit' ? 'green' : 'red';
+ return this.amount > 0 ? 'green' : 'red';
}
/** UI-specific: Type display */
@@ -41,13 +47,13 @@ export class WalletTransactionViewModel {
return this.type.charAt(0).toUpperCase() + this.type.slice(1);
}
- /** UI-specific: Amount color */
- get amountColor(): string {
- return this.type === 'deposit' ? 'green' : 'red';
+ /** UI-specific: Formatted date */
+ get formattedDate(): string {
+ return this.date.toLocaleDateString();
}
- /** UI-specific: Formatted created date */
- get formattedCreatedAt(): string {
- return new Date(this.createdAt).toLocaleString();
+ /** UI-specific: Is incoming */
+ get isIncoming(): boolean {
+ return this.amount > 0;
}
}
\ No newline at end of file
diff --git a/core/racing/application/ports/DriverExtendedProfileProvider.ts b/core/racing/application/ports/DriverExtendedProfileProvider.ts
new file mode 100644
index 000000000..c5bd252d7
--- /dev/null
+++ b/core/racing/application/ports/DriverExtendedProfileProvider.ts
@@ -0,0 +1,24 @@
+export interface DriverExtendedProfileProvider {
+ getExtendedProfile(driverId: string): {
+ socialHandles: {
+ platform: 'twitter' | 'youtube' | 'twitch' | 'discord';
+ handle: string;
+ url: string;
+ }[];
+ achievements: {
+ id: string;
+ title: string;
+ description: string;
+ icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap';
+ rarity: 'common' | 'rare' | 'epic' | 'legendary';
+ earnedAt: string;
+ }[];
+ racingStyle: string;
+ favoriteTrack: string;
+ favoriteCar: string;
+ timezone: string;
+ availableHours: string;
+ lookingForTeam: boolean;
+ openToRequests: boolean;
+ } | null;
+}
\ No newline at end of file
diff --git a/core/racing/application/ports/DriverRatingProvider.ts b/core/racing/application/ports/DriverRatingProvider.ts
new file mode 100644
index 000000000..3c59d58cd
--- /dev/null
+++ b/core/racing/application/ports/DriverRatingProvider.ts
@@ -0,0 +1,4 @@
+export interface DriverRatingProvider {
+ getRating(driverId: string): number | null;
+ getRatings(driverIds: string[]): Map;
+}
\ No newline at end of file
diff --git a/core/racing/application/ports/output/GetLeagueWalletOutputPort.ts b/core/racing/application/ports/output/GetLeagueWalletOutputPort.ts
new file mode 100644
index 000000000..3f1864dfb
--- /dev/null
+++ b/core/racing/application/ports/output/GetLeagueWalletOutputPort.ts
@@ -0,0 +1,23 @@
+export interface WalletTransactionOutputPort {
+ id: string;
+ type: 'sponsorship' | 'membership' | 'withdrawal' | 'prize';
+ description: string;
+ amount: number;
+ fee: number;
+ netAmount: number;
+ date: string;
+ status: 'completed' | 'pending' | 'failed';
+ reference?: string;
+}
+
+export interface GetLeagueWalletOutputPort {
+ balance: number;
+ currency: string;
+ totalRevenue: number;
+ totalFees: number;
+ totalWithdrawals: number;
+ pendingPayouts: number;
+ canWithdraw: boolean;
+ withdrawalBlockReason?: string;
+ transactions: WalletTransactionOutputPort[];
+}
\ No newline at end of file
diff --git a/core/racing/application/ports/output/ProfileOverviewOutputPort.ts b/core/racing/application/ports/output/ProfileOverviewOutputPort.ts
index cc2514d1c..972b060df 100644
--- a/core/racing/application/ports/output/ProfileOverviewOutputPort.ts
+++ b/core/racing/application/ports/output/ProfileOverviewOutputPort.ts
@@ -53,5 +53,26 @@ export interface ProfileOverviewOutputPort {
avatarUrl: string;
}[];
};
- extendedProfile: null;
+ extendedProfile: {
+ socialHandles: {
+ platform: 'twitter' | 'youtube' | 'twitch' | 'discord';
+ handle: string;
+ url: string;
+ }[];
+ achievements: {
+ id: string;
+ title: string;
+ description: string;
+ icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap';
+ rarity: 'common' | 'rare' | 'epic' | 'legendary';
+ earnedAt: string;
+ }[];
+ racingStyle: string;
+ favoriteTrack: string;
+ favoriteCar: string;
+ timezone: string;
+ availableHours: string;
+ lookingForTeam: boolean;
+ openToRequests: boolean;
+ } | null;
}
\ No newline at end of file
diff --git a/core/racing/application/ports/output/WithdrawFromLeagueWalletOutputPort.ts b/core/racing/application/ports/output/WithdrawFromLeagueWalletOutputPort.ts
new file mode 100644
index 000000000..392778411
--- /dev/null
+++ b/core/racing/application/ports/output/WithdrawFromLeagueWalletOutputPort.ts
@@ -0,0 +1,4 @@
+export interface WithdrawFromLeagueWalletOutputPort {
+ success: boolean;
+ message?: string;
+}
\ No newline at end of file
diff --git a/core/racing/application/use-cases/GetLeagueWalletUseCase.ts b/core/racing/application/use-cases/GetLeagueWalletUseCase.ts
new file mode 100644
index 000000000..40cdc0640
--- /dev/null
+++ b/core/racing/application/use-cases/GetLeagueWalletUseCase.ts
@@ -0,0 +1,99 @@
+import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository';
+import type { ITransactionRepository } from '../../domain/repositories/ITransactionRepository';
+import type { GetLeagueWalletOutputPort } from '../ports/output/GetLeagueWalletOutputPort';
+import { Result } from '@core/shared/application/Result';
+import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
+
+export interface GetLeagueWalletUseCaseParams {
+ leagueId: string;
+}
+
+/**
+ * Use Case for retrieving league wallet information.
+ */
+export class GetLeagueWalletUseCase {
+ constructor(
+ private readonly leagueWalletRepository: ILeagueWalletRepository,
+ private readonly transactionRepository: ITransactionRepository,
+ ) {}
+
+ async execute(
+ params: GetLeagueWalletUseCaseParams,
+ ): Promise>> {
+ try {
+ // For now, return mock data to emulate previous state
+ // TODO: Implement full domain logic when wallet entities are properly seeded
+ const mockWallet: GetLeagueWalletOutputPort = {
+ balance: 2450.00,
+ currency: 'USD',
+ totalRevenue: 3200.00,
+ totalFees: 320.00,
+ totalWithdrawals: 430.00,
+ pendingPayouts: 150.00,
+ canWithdraw: false,
+ withdrawalBlockReason: 'Season 2 is still active. Withdrawals are available after season completion.',
+ transactions: [
+ {
+ id: 'txn-1',
+ type: 'sponsorship',
+ description: 'Main Sponsor - TechCorp',
+ amount: 1200.00,
+ fee: 120.00,
+ netAmount: 1080.00,
+ date: '2025-12-01T00:00:00.000Z',
+ status: 'completed',
+ reference: 'SP-2025-001',
+ },
+ {
+ id: 'txn-2',
+ type: 'sponsorship',
+ description: 'Secondary Sponsor - RaceFuel',
+ amount: 400.00,
+ fee: 40.00,
+ netAmount: 360.00,
+ date: '2025-12-01T00:00:00.000Z',
+ status: 'completed',
+ reference: 'SP-2025-002',
+ },
+ {
+ id: 'txn-3',
+ type: 'membership',
+ description: 'Season Fee - 32 drivers',
+ amount: 1600.00,
+ fee: 160.00,
+ netAmount: 1440.00,
+ date: '2025-11-15T00:00:00.000Z',
+ status: 'completed',
+ reference: 'MF-2025-032',
+ },
+ {
+ id: 'txn-4',
+ type: 'withdrawal',
+ description: 'Bank Transfer - Season 1 Payout',
+ amount: -430.00,
+ fee: 0,
+ netAmount: -430.00,
+ date: '2025-10-30T00:00:00.000Z',
+ status: 'completed',
+ reference: 'WD-2025-001',
+ },
+ {
+ id: 'txn-5',
+ type: 'prize',
+ description: 'Championship Prize Pool (reserved)',
+ amount: -150.00,
+ fee: 0,
+ netAmount: -150.00,
+ date: '2025-12-05T00:00:00.000Z',
+ status: 'pending',
+ reference: 'PZ-2025-001',
+ },
+ ],
+ };
+
+ return Result.ok(mockWallet);
+ } catch {
+ return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch league wallet' });
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/racing/application/use-cases/GetProfileOverviewUseCase.ts b/core/racing/application/use-cases/GetProfileOverviewUseCase.ts
index 773b8f873..2d5258d2c 100644
--- a/core/racing/application/use-cases/GetProfileOverviewUseCase.ts
+++ b/core/racing/application/use-cases/GetProfileOverviewUseCase.ts
@@ -3,6 +3,7 @@ import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { IImageServicePort } from '../ports/IImageServicePort';
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
+import type { DriverExtendedProfileProvider } from '../ports/DriverExtendedProfileProvider';
import type { Driver } from '../../domain/entities/Driver';
import type { Team } from '../../domain/entities/Team';
import type { ProfileOverviewOutputPort } from '../ports/output/ProfileOverviewOutputPort';
@@ -40,6 +41,7 @@ export class GetProfileOverviewUseCase {
private readonly teamMembershipRepository: ITeamMembershipRepository,
private readonly socialRepository: ISocialGraphRepository,
private readonly imageService: IImageServicePort,
+ private readonly driverExtendedProfileProvider: DriverExtendedProfileProvider,
private readonly getDriverStats: (driverId: string) => ProfileDriverStatsAdapter | null,
private readonly getAllDriverRankings: () => DriverRankingEntry[],
) {}
@@ -65,6 +67,7 @@ export class GetProfileOverviewUseCase {
const finishDistribution = this.buildFinishDistribution(statsAdapter);
const teamMemberships = await this.buildTeamMemberships(driver.id, teams as Team[]);
const socialSummary = this.buildSocialSummary(friends as Driver[]);
+ const extendedProfile = this.driverExtendedProfileProvider.getExtendedProfile(driverId);
const outputPort: ProfileOverviewOutputPort = {
driver: driverSummary,
@@ -72,7 +75,7 @@ export class GetProfileOverviewUseCase {
finishDistribution,
teamMemberships,
socialSummary,
- extendedProfile: null,
+ extendedProfile,
};
return Result.ok(outputPort);
diff --git a/core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase.ts b/core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase.ts
new file mode 100644
index 000000000..5eba4349d
--- /dev/null
+++ b/core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase.ts
@@ -0,0 +1,66 @@
+import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository';
+import type { ITransactionRepository } from '../../domain/repositories/ITransactionRepository';
+import type { WithdrawFromLeagueWalletOutputPort } from '../ports/output/WithdrawFromLeagueWalletOutputPort';
+import { Result } from '@core/shared/application/Result';
+import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
+import { Money } from '../../domain/value-objects/Money';
+import { Transaction } from '../../domain/entities/league-wallet/Transaction';
+import { TransactionId } from '../../domain/entities/league-wallet/TransactionId';
+import { LeagueWalletId } from '../../domain/entities/league-wallet/LeagueWalletId';
+
+export interface WithdrawFromLeagueWalletUseCaseParams {
+ leagueId: string;
+ amount: number;
+ currency: string;
+ seasonId: string;
+ destinationAccount: string;
+}
+
+/**
+ * Use Case for withdrawing from league wallet.
+ */
+export class WithdrawFromLeagueWalletUseCase {
+ constructor(
+ private readonly leagueWalletRepository: ILeagueWalletRepository,
+ private readonly transactionRepository: ITransactionRepository,
+ ) {}
+
+ async execute(
+ params: WithdrawFromLeagueWalletUseCaseParams,
+ ): Promise>> {
+ try {
+ const wallet = await this.leagueWalletRepository.findByLeagueId(params.leagueId);
+ if (!wallet) {
+ return Result.err({ code: 'REPOSITORY_ERROR', message: 'Wallet not found' });
+ }
+
+ // Check if withdrawal is allowed (for now, always false as per mock)
+ if (!wallet.canWithdraw(Money.create(params.amount, params.currency))) {
+ return Result.err({ code: 'INSUFFICIENT_BALANCE', message: 'Insufficient balance for withdrawal' });
+ }
+
+ // For now, always block withdrawal
+ return Result.err({ code: 'WITHDRAWAL_NOT_ALLOWED', message: 'Season 2 is still active. Withdrawals are available after season completion.' });
+
+ // If allowed, create transaction and update wallet
+ // const transactionId = TransactionId.create(`txn-${Date.now()}`);
+ // const transaction = Transaction.create({
+ // id: transactionId,
+ // walletId: LeagueWalletId.create(wallet.id.toString()),
+ // type: 'withdrawal',
+ // amount: Money.create(params.amount, params.currency),
+ // description: `Bank Transfer - ${params.seasonId} Payout`,
+ // metadata: { destinationAccount: params.destinationAccount, seasonId: params.seasonId },
+ // });
+
+ // const updatedWallet = wallet.withdrawFunds(Money.create(params.amount, params.currency), transactionId.toString());
+
+ // await this.transactionRepository.create(transaction);
+ // await this.leagueWalletRepository.update(updatedWallet);
+
+ // return Result.ok({ success: true });
+ } catch {
+ return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to process withdrawal' });
+ }
+ }
+}
\ No newline at end of file