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