From 8ed6ba1fd189405c3cb38680e435cc5788a03c0c Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Tue, 16 Dec 2025 10:50:15 +0100 Subject: [PATCH] refactor --- .eslintrc.json | 46 +- .../InMemoryMembershipFeeRepository.ts | 77 ++ .../inmemory/InMemoryPaymentRepository.ts | 62 ++ .../inmemory/InMemoryPrizeRepository.ts | 54 ++ .../inmemory/InMemoryWalletRepository.ts | 56 ++ .../payments/persistence/inmemory/index.ts | 4 + .../src/modules/driver/DriverController.ts | 13 + apps/api/src/modules/media/MediaProviders.ts | 81 +- apps/api/src/modules/media/MediaService.ts | 38 +- .../RequestAvatarGenerationPresenter.ts | 28 + .../src/modules/payments/PaymentsProviders.ts | 144 ++- .../src/modules/payments/PaymentsService.ts | 430 +++------ .../presenters/AwardPrizePresenter.ts | 26 + .../presenters/CreatePaymentPresenter.ts | 26 + .../presenters/CreatePrizePresenter.ts | 26 + .../presenters/DeletePrizePresenter.ts | 26 + .../presenters/GetMembershipFeesPresenter.ts | 26 + .../presenters/GetPaymentsPresenter.ts | 26 + .../payments/presenters/GetPrizesPresenter.ts | 26 + .../payments/presenters/GetWalletPresenter.ts | 26 + .../ProcessWalletTransactionPresenter.ts | 26 + .../UpdateMemberPaymentPresenter.ts | 26 + .../UpdatePaymentStatusPresenter.ts | 26 + .../UpsertMembershipFeePresenter.ts | 26 + .../src/modules/payments/presenters/index.ts | 12 + apps/api/src/modules/race/RaceProviders.ts | 48 +- apps/api/src/modules/race/RaceService.ts | 61 +- .../race/presenters/GetAllRacesPresenter.ts | 17 + .../race/presenters/GetTotalRacesPresenter.ts | 20 + .../ImportRaceResultsApiPresenter.ts | 17 + .../src/modules/sponsor/SponsorProviders.ts | 131 ++- .../api/src/modules/sponsor/SponsorService.ts | 190 +--- .../presenters/CreateSponsorPresenter.ts | 22 + .../GetEntitySponsorshipPricingPresenter.ts | 22 + .../GetSponsorDashboardPresenter.ts | 22 + .../GetSponsorSponsorshipsPresenter.ts | 22 + .../presenters/GetSponsorsPresenter.ts | 22 + .../GetSponsorshipPricingPresenter.ts | 22 + apps/api/src/modules/team/TeamController.ts | 82 +- apps/api/src/modules/team/TeamProviders.ts | 101 +- apps/api/src/modules/team/TeamService.ts | 130 ++- apps/api/src/modules/team/dto/TeamDto.ts | 314 ++++-- .../team/presenters/AllTeamsPresenter.ts | 7 +- .../team/presenters/DriverTeamPresenter.ts | 37 +- .../team/presenters/TeamDetailsPresenter.ts | 50 + .../presenters/TeamJoinRequestsPresenter.ts | 43 + .../team/presenters/TeamMembersPresenter.ts | 48 + apps/api/tsconfig.json | 9 + apps/website/app/auth/iracing/start/route.ts | 7 +- apps/website/lib/apiClient.ts | 913 +++++++++++++++++- apps/website/lib/app.module.ts | 25 - apps/website/lib/currentDriver.ts | 31 +- apps/website/lib/di-setup.ts | 25 - apps/website/lib/leagueMembership.ts | 118 +-- apps/website/lib/leagueRoles.ts | 6 +- apps/website/lib/leagueWizardService.ts | 178 ++-- .../lib/modules/analytics/AnalyticsModule.ts | 9 - .../modules/analytics/AnalyticsProviders.ts | 30 - apps/website/lib/modules/auth/AuthModule.ts | 9 - .../website/lib/modules/auth/AuthProviders.ts | 30 - .../lib/modules/driver/DriverModule.ts | 9 - .../lib/modules/driver/DriverProviders.ts | 22 - .../lib/modules/league/LeagueModule.ts | 9 - .../lib/modules/league/LeagueProviders.ts | 30 - .../lib/modules/logging/LoggingModule.ts | 17 - apps/website/lib/modules/media/MediaModule.ts | 9 - .../lib/modules/media/MediaProviders.ts | 22 - apps/website/lib/modules/race/RaceModule.ts | 9 - .../website/lib/modules/race/RaceProviders.ts | 22 - .../lib/modules/sponsor/SponsorModule.ts | 9 - .../lib/modules/sponsor/SponsorProviders.ts | 22 - apps/website/lib/modules/team/TeamModule.ts | 9 - .../website/lib/modules/team/TeamProviders.ts | 22 - ...lLeaguesWithCapacityAndScoringPresenter.ts | 208 ++-- .../lib/presenters/AllTeamsPresenter.ts | 84 +- .../lib/presenters/DriverTeamPresenter.ts | 102 +- .../presenters/DriversLeaderboardPresenter.ts | 156 +-- .../lib/presenters/LeagueAdminPresenter.ts | 355 +++---- .../presenters/LeagueStandingsPresenter.ts | 64 +- .../presenters/ScheduleRaceFormPresenter.ts | 45 +- .../lib/presenters/TeamAdminPresenter.ts | 108 +-- .../lib/presenters/TeamDetailsPresenter.ts | 98 +- .../lib/presenters/TeamRosterPresenter.ts | 58 +- .../lib/presenters/TeamStandingsPresenter.ts | 79 +- .../lib/services/LeagueMembershipService.ts | 101 -- .../IRequestAvatarGenerationPresenter.ts | 13 + .../RequestAvatarGenerationUseCase.ts | 43 +- core/payments/application/index.ts | 2 + .../presenters/IAwardPrizePresenter.ts | 16 + .../presenters/ICreatePaymentPresenter.ts | 16 + .../presenters/ICreatePrizePresenter.ts | 16 + .../presenters/IDeletePrizePresenter.ts | 15 + .../presenters/IGetMembershipFeesPresenter.ts | 42 + .../presenters/IGetPaymentsPresenter.ts | 31 + .../presenters/IGetPrizesPresenter.ts | 31 + .../presenters/IGetWalletPresenter.ts | 40 + .../IProcessWalletTransactionPresenter.ts | 18 + .../IUpdateMemberPaymentPresenter.ts | 16 + .../IUpdatePaymentStatusPresenter.ts | 16 + .../IUpsertMembershipFeePresenter.ts | 16 + core/payments/application/presenters/index.ts | 12 + .../use-cases/AwardPrizeUseCase.ts | 67 ++ .../use-cases/CreatePaymentUseCase.ts | 79 ++ .../use-cases/CreatePrizeUseCase.ts | 79 ++ .../use-cases/DeletePrizeUseCase.ts | 49 + .../use-cases/GetMembershipFeesUseCase.ts | 74 ++ .../use-cases/GetPaymentsUseCase.ts | 58 ++ .../application/use-cases/GetPrizesUseCase.ts | 61 ++ .../application/use-cases/GetWalletUseCase.ts | 85 ++ .../ProcessWalletTransactionUseCase.ts | 121 +++ .../use-cases/UpdateMemberPaymentUseCase.ts | 91 ++ .../use-cases/UpdatePaymentStatusUseCase.ts | 65 ++ .../use-cases/UpsertMembershipFeeUseCase.ts | 76 ++ core/payments/application/use-cases/index.ts | 12 + .../payments/domain/entities/MemberPayment.ts | 21 + .../payments/domain/entities/MembershipFee.ts | 20 + core/payments/domain/entities/Payment.ts | 35 + core/payments/domain/entities/Prize.ts | 24 + core/payments/domain/entities/Wallet.ts | 37 + core/payments/domain/entities/index.ts | 5 + core/payments/domain/index.ts | 2 + .../repositories/IMembershipFeeRepository.ts | 21 + .../domain/repositories/IPaymentRepository.ts | 15 + .../domain/repositories/IPrizeRepository.ts | 15 + .../domain/repositories/IWalletRepository.ts | 18 + core/payments/domain/repositories/index.ts | 4 + core/payments/index.ts | 2 + core/racing/application/index.ts | 3 + .../presenters/ICreateSponsorPresenter.ts | 20 + .../IEntitySponsorshipPricingPresenter.ts | 5 +- .../presenters/IGetAllRacesPresenter.ts | 20 + .../presenters/IGetSponsorsPresenter.ts | 20 + .../IGetSponsorshipPricingPresenter.ts | 18 + .../presenters/IGetTotalRacesPresenter.ts | 11 + .../IImportRaceResultsApiPresenter.ts | 19 + .../use-cases/CreateSponsorUseCase.ts | 61 ++ .../use-cases/GetAllRacesUseCase.ts | 34 + .../GetEntitySponsorshipPricingUseCase.ts | 52 +- .../use-cases/GetSponsorsUseCase.ts | 43 + .../use-cases/GetSponsorshipPricingUseCase.ts | 35 + .../use-cases/GetTotalRacesUseCase.ts | 20 + .../use-cases/ImportRaceResultsApiUseCase.ts | 25 + package-lock.json | 38 + package.json | 1 + 144 files changed, 5763 insertions(+), 1985 deletions(-) create mode 100644 adapters/payments/persistence/inmemory/InMemoryMembershipFeeRepository.ts create mode 100644 adapters/payments/persistence/inmemory/InMemoryPaymentRepository.ts create mode 100644 adapters/payments/persistence/inmemory/InMemoryPrizeRepository.ts create mode 100644 adapters/payments/persistence/inmemory/InMemoryWalletRepository.ts create mode 100644 adapters/payments/persistence/inmemory/index.ts create mode 100644 apps/api/src/modules/media/presenters/RequestAvatarGenerationPresenter.ts create mode 100644 apps/api/src/modules/payments/presenters/AwardPrizePresenter.ts create mode 100644 apps/api/src/modules/payments/presenters/CreatePaymentPresenter.ts create mode 100644 apps/api/src/modules/payments/presenters/CreatePrizePresenter.ts create mode 100644 apps/api/src/modules/payments/presenters/DeletePrizePresenter.ts create mode 100644 apps/api/src/modules/payments/presenters/GetMembershipFeesPresenter.ts create mode 100644 apps/api/src/modules/payments/presenters/GetPaymentsPresenter.ts create mode 100644 apps/api/src/modules/payments/presenters/GetPrizesPresenter.ts create mode 100644 apps/api/src/modules/payments/presenters/GetWalletPresenter.ts create mode 100644 apps/api/src/modules/payments/presenters/ProcessWalletTransactionPresenter.ts create mode 100644 apps/api/src/modules/payments/presenters/UpdateMemberPaymentPresenter.ts create mode 100644 apps/api/src/modules/payments/presenters/UpdatePaymentStatusPresenter.ts create mode 100644 apps/api/src/modules/payments/presenters/UpsertMembershipFeePresenter.ts create mode 100644 apps/api/src/modules/payments/presenters/index.ts create mode 100644 apps/api/src/modules/race/presenters/GetAllRacesPresenter.ts create mode 100644 apps/api/src/modules/race/presenters/GetTotalRacesPresenter.ts create mode 100644 apps/api/src/modules/race/presenters/ImportRaceResultsApiPresenter.ts create mode 100644 apps/api/src/modules/sponsor/presenters/CreateSponsorPresenter.ts create mode 100644 apps/api/src/modules/sponsor/presenters/GetEntitySponsorshipPricingPresenter.ts create mode 100644 apps/api/src/modules/sponsor/presenters/GetSponsorDashboardPresenter.ts create mode 100644 apps/api/src/modules/sponsor/presenters/GetSponsorSponsorshipsPresenter.ts create mode 100644 apps/api/src/modules/sponsor/presenters/GetSponsorsPresenter.ts create mode 100644 apps/api/src/modules/sponsor/presenters/GetSponsorshipPricingPresenter.ts create mode 100644 apps/api/src/modules/team/presenters/TeamDetailsPresenter.ts create mode 100644 apps/api/src/modules/team/presenters/TeamJoinRequestsPresenter.ts create mode 100644 apps/api/src/modules/team/presenters/TeamMembersPresenter.ts delete mode 100644 apps/website/lib/app.module.ts delete mode 100644 apps/website/lib/di-setup.ts delete mode 100644 apps/website/lib/modules/analytics/AnalyticsModule.ts delete mode 100644 apps/website/lib/modules/analytics/AnalyticsProviders.ts delete mode 100644 apps/website/lib/modules/auth/AuthModule.ts delete mode 100644 apps/website/lib/modules/auth/AuthProviders.ts delete mode 100644 apps/website/lib/modules/driver/DriverModule.ts delete mode 100644 apps/website/lib/modules/driver/DriverProviders.ts delete mode 100644 apps/website/lib/modules/league/LeagueModule.ts delete mode 100644 apps/website/lib/modules/league/LeagueProviders.ts delete mode 100644 apps/website/lib/modules/logging/LoggingModule.ts delete mode 100644 apps/website/lib/modules/media/MediaModule.ts delete mode 100644 apps/website/lib/modules/media/MediaProviders.ts delete mode 100644 apps/website/lib/modules/race/RaceModule.ts delete mode 100644 apps/website/lib/modules/race/RaceProviders.ts delete mode 100644 apps/website/lib/modules/sponsor/SponsorModule.ts delete mode 100644 apps/website/lib/modules/sponsor/SponsorProviders.ts delete mode 100644 apps/website/lib/modules/team/TeamModule.ts delete mode 100644 apps/website/lib/modules/team/TeamProviders.ts delete mode 100644 apps/website/lib/services/LeagueMembershipService.ts create mode 100644 core/media/application/presenters/IRequestAvatarGenerationPresenter.ts create mode 100644 core/payments/application/index.ts create mode 100644 core/payments/application/presenters/IAwardPrizePresenter.ts create mode 100644 core/payments/application/presenters/ICreatePaymentPresenter.ts create mode 100644 core/payments/application/presenters/ICreatePrizePresenter.ts create mode 100644 core/payments/application/presenters/IDeletePrizePresenter.ts create mode 100644 core/payments/application/presenters/IGetMembershipFeesPresenter.ts create mode 100644 core/payments/application/presenters/IGetPaymentsPresenter.ts create mode 100644 core/payments/application/presenters/IGetPrizesPresenter.ts create mode 100644 core/payments/application/presenters/IGetWalletPresenter.ts create mode 100644 core/payments/application/presenters/IProcessWalletTransactionPresenter.ts create mode 100644 core/payments/application/presenters/IUpdateMemberPaymentPresenter.ts create mode 100644 core/payments/application/presenters/IUpdatePaymentStatusPresenter.ts create mode 100644 core/payments/application/presenters/IUpsertMembershipFeePresenter.ts create mode 100644 core/payments/application/presenters/index.ts create mode 100644 core/payments/application/use-cases/AwardPrizeUseCase.ts create mode 100644 core/payments/application/use-cases/CreatePaymentUseCase.ts create mode 100644 core/payments/application/use-cases/CreatePrizeUseCase.ts create mode 100644 core/payments/application/use-cases/DeletePrizeUseCase.ts create mode 100644 core/payments/application/use-cases/GetMembershipFeesUseCase.ts create mode 100644 core/payments/application/use-cases/GetPaymentsUseCase.ts create mode 100644 core/payments/application/use-cases/GetPrizesUseCase.ts create mode 100644 core/payments/application/use-cases/GetWalletUseCase.ts create mode 100644 core/payments/application/use-cases/ProcessWalletTransactionUseCase.ts create mode 100644 core/payments/application/use-cases/UpdateMemberPaymentUseCase.ts create mode 100644 core/payments/application/use-cases/UpdatePaymentStatusUseCase.ts create mode 100644 core/payments/application/use-cases/UpsertMembershipFeeUseCase.ts create mode 100644 core/payments/application/use-cases/index.ts create mode 100644 core/payments/domain/entities/MemberPayment.ts create mode 100644 core/payments/domain/entities/MembershipFee.ts create mode 100644 core/payments/domain/entities/Payment.ts create mode 100644 core/payments/domain/entities/Prize.ts create mode 100644 core/payments/domain/entities/Wallet.ts create mode 100644 core/payments/domain/entities/index.ts create mode 100644 core/payments/domain/index.ts create mode 100644 core/payments/domain/repositories/IMembershipFeeRepository.ts create mode 100644 core/payments/domain/repositories/IPaymentRepository.ts create mode 100644 core/payments/domain/repositories/IPrizeRepository.ts create mode 100644 core/payments/domain/repositories/IWalletRepository.ts create mode 100644 core/payments/domain/repositories/index.ts create mode 100644 core/payments/index.ts create mode 100644 core/racing/application/presenters/ICreateSponsorPresenter.ts create mode 100644 core/racing/application/presenters/IGetAllRacesPresenter.ts create mode 100644 core/racing/application/presenters/IGetSponsorsPresenter.ts create mode 100644 core/racing/application/presenters/IGetSponsorshipPricingPresenter.ts create mode 100644 core/racing/application/presenters/IGetTotalRacesPresenter.ts create mode 100644 core/racing/application/presenters/IImportRaceResultsApiPresenter.ts create mode 100644 core/racing/application/use-cases/CreateSponsorUseCase.ts create mode 100644 core/racing/application/use-cases/GetAllRacesUseCase.ts create mode 100644 core/racing/application/use-cases/GetSponsorsUseCase.ts create mode 100644 core/racing/application/use-cases/GetSponsorshipPricingUseCase.ts create mode 100644 core/racing/application/use-cases/GetTotalRacesUseCase.ts create mode 100644 core/racing/application/use-cases/ImportRaceResultsApiUseCase.ts diff --git a/.eslintrc.json b/.eslintrc.json index d8617afb7..69b553a25 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -8,11 +8,31 @@ "sourceType": "module", "ecmaVersion": 2022 }, + "settings": { + "boundaries/elements": [ + { + "type": "website", + "pattern": "apps/website/**/*" + }, + { + "type": "api", + "pattern": "apps/api/**/*" + }, + { + "type": "adapters", + "pattern": "adapters/**/*" + }, + { + "type": "core", + "pattern": "core/**/*" + } + ] + }, "overrides": [ { "files": ["**/*.ts", "**/*.tsx"], "parser": "@typescript-eslint/parser", - "plugins": ["@typescript-eslint"], + "plugins": ["@typescript-eslint", "boundaries"], "extends": [], "rules": { "@typescript-eslint/no-explicit-any": "error", @@ -25,6 +45,30 @@ "varsIgnorePattern": "^$", "caughtErrors": "all" } + ], + "boundaries/element-types": [ + 2, + { + "default": "disallow", + "rules": [ + { + "from": ["website"], + "allow": ["website"] + }, + { + "from": ["api"], + "allow": ["api", "adapters", "core"] + }, + { + "from": ["adapters"], + "allow": ["adapters", "core"] + }, + { + "from": ["core"], + "allow": ["core"] + } + ] + } ] } } diff --git a/adapters/payments/persistence/inmemory/InMemoryMembershipFeeRepository.ts b/adapters/payments/persistence/inmemory/InMemoryMembershipFeeRepository.ts new file mode 100644 index 000000000..c9d93bdf2 --- /dev/null +++ b/adapters/payments/persistence/inmemory/InMemoryMembershipFeeRepository.ts @@ -0,0 +1,77 @@ +/** + * In-Memory Implementation: InMemoryMembershipFeeRepository + */ + +import type { Logger } from '@gridpilot/shared/application/Logger'; +import type { IMembershipFeeRepository, IMemberPaymentRepository } from '../../../../core/payments/domain/repositories/IMembershipFeeRepository'; +import type { MembershipFee } from '../../../../core/payments/domain/entities/MembershipFee'; +import type { MemberPayment } from '../../../../core/payments/domain/entities/MemberPayment'; + +const membershipFees: Map = new Map(); +const memberPayments: Map = new Map(); + +export class InMemoryMembershipFeeRepository implements IMembershipFeeRepository { + constructor(private readonly logger: Logger) {} + + async findById(id: string): Promise { + this.logger.debug('[InMemoryMembershipFeeRepository] findById', { id }); + return membershipFees.get(id) || null; + } + + async findByLeagueId(leagueId: string): Promise { + this.logger.debug('[InMemoryMembershipFeeRepository] findByLeagueId', { leagueId }); + return Array.from(membershipFees.values()).find(f => f.leagueId === leagueId) || null; + } + + async create(fee: MembershipFee): Promise { + this.logger.debug('[InMemoryMembershipFeeRepository] create', { fee }); + membershipFees.set(fee.id, fee); + return fee; + } + + async update(fee: MembershipFee): Promise { + this.logger.debug('[InMemoryMembershipFeeRepository] update', { fee }); + membershipFees.set(fee.id, fee); + return fee; + } +} + +export class InMemoryMemberPaymentRepository implements IMemberPaymentRepository { + constructor(private readonly logger: Logger) {} + + async findById(id: string): Promise { + this.logger.debug('[InMemoryMemberPaymentRepository] findById', { id }); + return memberPayments.get(id) || null; + } + + async findByFeeIdAndDriverId(feeId: string, driverId: string): Promise { + this.logger.debug('[InMemoryMemberPaymentRepository] findByFeeIdAndDriverId', { feeId, driverId }); + return Array.from(memberPayments.values()).find( + p => p.feeId === feeId && p.driverId === driverId + ) || null; + } + + async findByLeagueIdAndDriverId(leagueId: string, driverId: string, membershipFeeRepo: IMembershipFeeRepository): Promise { + this.logger.debug('[InMemoryMemberPaymentRepository] findByLeagueIdAndDriverId', { leagueId, driverId }); + const results: MemberPayment[] = []; + for (const payment of memberPayments.values()) { + const fee = await membershipFeeRepo.findById(payment.feeId); + if (fee && fee.leagueId === leagueId && payment.driverId === driverId) { + results.push(payment); + } + } + return results; + } + + async create(payment: MemberPayment): Promise { + this.logger.debug('[InMemoryMemberPaymentRepository] create', { payment }); + memberPayments.set(payment.id, payment); + return payment; + } + + async update(payment: MemberPayment): Promise { + this.logger.debug('[InMemoryMemberPaymentRepository] update', { payment }); + memberPayments.set(payment.id, payment); + return payment; + } +} \ No newline at end of file diff --git a/adapters/payments/persistence/inmemory/InMemoryPaymentRepository.ts b/adapters/payments/persistence/inmemory/InMemoryPaymentRepository.ts new file mode 100644 index 000000000..75fbae616 --- /dev/null +++ b/adapters/payments/persistence/inmemory/InMemoryPaymentRepository.ts @@ -0,0 +1,62 @@ +/** + * In-Memory Implementation: InMemoryPaymentRepository + */ + +import type { Logger } from '@gridpilot/shared/application/Logger'; +import type { IPaymentRepository } from '../../../../core/payments/domain/repositories/IPaymentRepository'; +import type { Payment, PaymentType } from '../../../../core/payments/domain/entities/Payment'; + +const payments: Map = new Map(); + +export class InMemoryPaymentRepository implements IPaymentRepository { + constructor(private readonly logger: Logger) {} + + async findById(id: string): Promise { + this.logger.debug('[InMemoryPaymentRepository] findById', { id }); + return payments.get(id) || null; + } + + async findByLeagueId(leagueId: string): Promise { + this.logger.debug('[InMemoryPaymentRepository] findByLeagueId', { leagueId }); + return Array.from(payments.values()).filter(p => p.leagueId === leagueId); + } + + async findByPayerId(payerId: string): Promise { + this.logger.debug('[InMemoryPaymentRepository] findByPayerId', { payerId }); + return Array.from(payments.values()).filter(p => p.payerId === payerId); + } + + async findByType(type: PaymentType): Promise { + this.logger.debug('[InMemoryPaymentRepository] findByType', { type }); + return Array.from(payments.values()).filter(p => p.type === type); + } + + async findByFilters(filters: { leagueId?: string; payerId?: string; type?: PaymentType }): Promise { + this.logger.debug('[InMemoryPaymentRepository] findByFilters', { filters }); + let results = Array.from(payments.values()); + + if (filters.leagueId) { + results = results.filter(p => p.leagueId === filters.leagueId); + } + if (filters.payerId) { + results = results.filter(p => p.payerId === filters.payerId); + } + if (filters.type) { + results = results.filter(p => p.type === filters.type); + } + + return results; + } + + async create(payment: Payment): Promise { + this.logger.debug('[InMemoryPaymentRepository] create', { payment }); + payments.set(payment.id, payment); + return payment; + } + + async update(payment: Payment): Promise { + this.logger.debug('[InMemoryPaymentRepository] update', { payment }); + payments.set(payment.id, payment); + return payment; + } +} \ No newline at end of file diff --git a/adapters/payments/persistence/inmemory/InMemoryPrizeRepository.ts b/adapters/payments/persistence/inmemory/InMemoryPrizeRepository.ts new file mode 100644 index 000000000..ce914c24b --- /dev/null +++ b/adapters/payments/persistence/inmemory/InMemoryPrizeRepository.ts @@ -0,0 +1,54 @@ +/** + * In-Memory Implementation: InMemoryPrizeRepository + */ + +import type { Logger } from '@gridpilot/shared/application/Logger'; +import type { IPrizeRepository } from '../../../../core/payments/domain/repositories/IPrizeRepository'; +import type { Prize } from '../../../../core/payments/domain/entities/Prize'; + +const prizes: Map = new Map(); + +export class InMemoryPrizeRepository implements IPrizeRepository { + constructor(private readonly logger: Logger) {} + + async findById(id: string): Promise { + this.logger.debug('[InMemoryPrizeRepository] findById', { id }); + return prizes.get(id) || null; + } + + async findByLeagueId(leagueId: string): Promise { + this.logger.debug('[InMemoryPrizeRepository] findByLeagueId', { leagueId }); + return Array.from(prizes.values()).filter(p => p.leagueId === leagueId); + } + + async findByLeagueIdAndSeasonId(leagueId: string, seasonId: string): Promise { + this.logger.debug('[InMemoryPrizeRepository] findByLeagueIdAndSeasonId', { leagueId, seasonId }); + return Array.from(prizes.values()).filter( + p => p.leagueId === leagueId && p.seasonId === seasonId + ); + } + + async findByPosition(leagueId: string, seasonId: string, position: number): Promise { + this.logger.debug('[InMemoryPrizeRepository] findByPosition', { leagueId, seasonId, position }); + return Array.from(prizes.values()).find( + p => p.leagueId === leagueId && p.seasonId === seasonId && p.position === position + ) || null; + } + + async create(prize: Prize): Promise { + this.logger.debug('[InMemoryPrizeRepository] create', { prize }); + prizes.set(prize.id, prize); + return prize; + } + + async update(prize: Prize): Promise { + this.logger.debug('[InMemoryPrizeRepository] update', { prize }); + prizes.set(prize.id, prize); + return prize; + } + + async delete(id: string): Promise { + this.logger.debug('[InMemoryPrizeRepository] delete', { id }); + prizes.delete(id); + } +} \ No newline at end of file diff --git a/adapters/payments/persistence/inmemory/InMemoryWalletRepository.ts b/adapters/payments/persistence/inmemory/InMemoryWalletRepository.ts new file mode 100644 index 000000000..c19761e2e --- /dev/null +++ b/adapters/payments/persistence/inmemory/InMemoryWalletRepository.ts @@ -0,0 +1,56 @@ +/** + * In-Memory Implementation: InMemoryWalletRepository + */ + +import type { Logger } from '@gridpilot/shared/application/Logger'; +import type { IWalletRepository, ITransactionRepository } from '../../../../core/payments/domain/repositories/IWalletRepository'; +import type { Wallet, Transaction } from '../../../../core/payments/domain/entities/Wallet'; + +const wallets: Map = new Map(); +const transactions: Map = new Map(); + +export class InMemoryWalletRepository implements IWalletRepository { + constructor(private readonly logger: Logger) {} + + async findById(id: string): Promise { + this.logger.debug('[InMemoryWalletRepository] findById', { id }); + return wallets.get(id) || null; + } + + async findByLeagueId(leagueId: string): Promise { + this.logger.debug('[InMemoryWalletRepository] findByLeagueId', { leagueId }); + return Array.from(wallets.values()).find(w => w.leagueId === leagueId) || null; + } + + async create(wallet: Wallet): Promise { + this.logger.debug('[InMemoryWalletRepository] create', { wallet }); + wallets.set(wallet.id, wallet); + return wallet; + } + + async update(wallet: Wallet): Promise { + this.logger.debug('[InMemoryWalletRepository] update', { wallet }); + wallets.set(wallet.id, wallet); + return wallet; + } +} + +export class InMemoryTransactionRepository implements ITransactionRepository { + constructor(private readonly logger: Logger) {} + + async findById(id: string): Promise { + this.logger.debug('[InMemoryTransactionRepository] findById', { id }); + return transactions.get(id) || null; + } + + async findByWalletId(walletId: string): Promise { + this.logger.debug('[InMemoryTransactionRepository] findByWalletId', { walletId }); + return Array.from(transactions.values()).filter(t => t.walletId === walletId); + } + + async create(transaction: Transaction): Promise { + this.logger.debug('[InMemoryTransactionRepository] create', { transaction }); + transactions.set(transaction.id, transaction); + return transaction; + } +} \ No newline at end of file diff --git a/adapters/payments/persistence/inmemory/index.ts b/adapters/payments/persistence/inmemory/index.ts new file mode 100644 index 000000000..9fc96ae36 --- /dev/null +++ b/adapters/payments/persistence/inmemory/index.ts @@ -0,0 +1,4 @@ +export * from './InMemoryPaymentRepository'; +export * from './InMemoryMembershipFeeRepository'; +export * from './InMemoryPrizeRepository'; +export * from './InMemoryWalletRepository'; \ No newline at end of file diff --git a/apps/api/src/modules/driver/DriverController.ts b/apps/api/src/modules/driver/DriverController.ts index 23ee2b1e1..3c04c74f2 100644 --- a/apps/api/src/modules/driver/DriverController.ts +++ b/apps/api/src/modules/driver/DriverController.ts @@ -23,6 +23,19 @@ export class DriverController { return this.driverService.getTotalDrivers(); } + @Get('current') + @ApiOperation({ summary: 'Get current authenticated driver' }) + @ApiResponse({ status: 200, description: 'Current driver data', type: DriverDTO }) + @ApiResponse({ status: 404, description: 'Driver not found' }) + async getCurrentDriver(@Req() req: Request): Promise { + // Assuming userId is available from the request (e.g., via auth middleware) + const userId = req['user']?.userId; + if (!userId) { + return null; + } + return this.driverService.getCurrentDriver(userId); + } + @Post('complete-onboarding') @ApiOperation({ summary: 'Complete driver onboarding for a user' }) @ApiResponse({ status: 200, description: 'Onboarding complete', type: CompleteOnboardingOutput }) diff --git a/apps/api/src/modules/media/MediaProviders.ts b/apps/api/src/modules/media/MediaProviders.ts index fc1288bf3..eedecec68 100644 --- a/apps/api/src/modules/media/MediaProviders.ts +++ b/apps/api/src/modules/media/MediaProviders.ts @@ -1,41 +1,82 @@ import { Provider } from '@nestjs/common'; import { MediaService } from './MediaService'; -// Due to persistent module resolution issues in the environment, -// actual core interfaces and adapter implementations are not directly imported here. -// In a functional TypeScript environment, these would be imported as follows: -/* - import { IAvatarGenerationRepository } from 'core/media/domain/repositories/IAvatarGenerationRepository'; - import { FaceValidationPort } from 'core/media/application/ports/FaceValidationPort'; - import { Logger } from '@gridpilot/shared/logging/Logger'; +// Import core interfaces +import { IAvatarGenerationRepository } from '@gridpilot/media/domain/repositories/IAvatarGenerationRepository'; +import { FaceValidationPort } from '@gridpilot/media/application/ports/FaceValidationPort'; +import { AvatarGenerationPort } from '@gridpilot/media/application/ports/AvatarGenerationPort'; +import { Logger } from '@gridpilot/shared/application'; - import { InMemoryAvatarGenerationRepository } from 'adapters/media/persistence/inmemory/InMemoryAvatarGenerationRepository'; - import { InMemoryFaceValidationAdapter } from 'adapters/media/ports/InMemoryFaceValidationAdapter'; - import { ConsoleLogger } from 'adapters/logging/ConsoleLogger'; -*/ +// Import use cases +import { RequestAvatarGenerationUseCase } from '@gridpilot/media/application/use-cases/RequestAvatarGenerationUseCase'; -// Define injection tokens as string literals for NestJS +// Define injection tokens export const AVATAR_GENERATION_REPOSITORY_TOKEN = 'IAvatarGenerationRepository'; export const FACE_VALIDATION_PORT_TOKEN = 'FaceValidationPort'; +export const AVATAR_GENERATION_PORT_TOKEN = 'AvatarGenerationPort'; export const LOGGER_TOKEN = 'Logger'; +// Use case tokens +export const REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN = 'RequestAvatarGenerationUseCase'; + +// Mock implementations +class MockAvatarGenerationRepository implements IAvatarGenerationRepository { + async save(_request: any): Promise {} + async findById(_id: string): Promise { return null; } + async findByUserId(_userId: string): Promise { return []; } + async findLatestByUserId(_userId: string): Promise { return null; } + async delete(_id: string): Promise {} +} + +class MockFaceValidationAdapter implements FaceValidationPort { + async validateFacePhoto(data: string): Promise { + return { isValid: true, hasFace: true, faceCount: 1 }; + } +} + +class MockAvatarGenerationAdapter implements AvatarGenerationPort { + async generateAvatars(options: any): Promise { + return { + success: true, + avatars: [ + { url: 'https://cdn.example.com/avatars/mock-avatar-1.png' }, + { url: 'https://cdn.example.com/avatars/mock-avatar-2.png' }, + { url: 'https://cdn.example.com/avatars/mock-avatar-3.png' }, + ], + }; + } +} + +class MockLogger implements Logger { + debug(message: string, meta?: any): void {} + info(message: string, meta?: any): void {} + warn(message: string, meta?: any): void {} + error(message: string, error?: Error): void {} +} + export const MediaProviders: Provider[] = [ MediaService, // Provide the service itself - // In a functional setup, the following would be enabled: - /* { provide: AVATAR_GENERATION_REPOSITORY_TOKEN, - useFactory: (logger: Logger) => new InMemoryAvatarGenerationRepository(logger), - inject: [LOGGER_TOKEN], + useClass: MockAvatarGenerationRepository, }, { provide: FACE_VALIDATION_PORT_TOKEN, - useFactory: (logger: Logger) => new InMemoryFaceValidationAdapter(logger), - inject: [LOGGER_TOKEN], + useClass: MockFaceValidationAdapter, + }, + { + provide: AVATAR_GENERATION_PORT_TOKEN, + useClass: MockAvatarGenerationAdapter, }, { provide: LOGGER_TOKEN, - useClass: ConsoleLogger, + useClass: MockLogger, + }, + // Use cases + { + provide: REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN, + useFactory: (avatarRepo: IAvatarGenerationRepository, faceValidation: FaceValidationPort, avatarGeneration: AvatarGenerationPort, logger: Logger) => + new RequestAvatarGenerationUseCase(avatarRepo, faceValidation, avatarGeneration, logger), + inject: [AVATAR_GENERATION_REPOSITORY_TOKEN, FACE_VALIDATION_PORT_TOKEN, AVATAR_GENERATION_PORT_TOKEN, LOGGER_TOKEN], }, - */ ]; diff --git a/apps/api/src/modules/media/MediaService.ts b/apps/api/src/modules/media/MediaService.ts index cc89ab71a..9baf15c97 100644 --- a/apps/api/src/modules/media/MediaService.ts +++ b/apps/api/src/modules/media/MediaService.ts @@ -1,20 +1,32 @@ -import { Injectable } from '@nestjs/common'; -import { RequestAvatarGenerationInput, RequestAvatarGenerationOutput } from './dto/MediaDto'; // Assuming these DTOs are defined +import { Injectable, Inject } from '@nestjs/common'; +import { RequestAvatarGenerationInput, RequestAvatarGenerationOutput } from './dto/MediaDto'; + +// Use cases +import { RequestAvatarGenerationUseCase } from '@gridpilot/media/application/use-cases/RequestAvatarGenerationUseCase'; + +// Presenters +import { RequestAvatarGenerationPresenter } from './presenters/RequestAvatarGenerationPresenter'; + +// Tokens +import { REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN, LOGGER_TOKEN } from './MediaProviders'; +import { Logger } from '@gridpilot/shared/application'; @Injectable() export class MediaService { - - constructor() {} + constructor( + @Inject(REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN) private readonly requestAvatarGenerationUseCase: RequestAvatarGenerationUseCase, + @Inject(LOGGER_TOKEN) private readonly logger: Logger, + ) {} async requestAvatarGeneration(input: RequestAvatarGenerationInput): Promise { - console.log('[MediaService] Returning mock avatar generation request. Input:', input); - return { - success: true, - requestId: `req-${Date.now()}`, - avatarUrls: [ - 'https://cdn.example.com/avatars/mock-avatar-1.png', - 'https://cdn.example.com/avatars/mock-avatar-2.png', - ], - }; + this.logger.debug('[MediaService] Requesting avatar generation.'); + + const presenter = new RequestAvatarGenerationPresenter(); + await this.requestAvatarGenerationUseCase.execute({ + userId: input.userId, + facePhotoData: input.facePhotoData, + suitColor: input.suitColor as any, + }, presenter); + return presenter.viewModel; } } diff --git a/apps/api/src/modules/media/presenters/RequestAvatarGenerationPresenter.ts b/apps/api/src/modules/media/presenters/RequestAvatarGenerationPresenter.ts new file mode 100644 index 000000000..84858a490 --- /dev/null +++ b/apps/api/src/modules/media/presenters/RequestAvatarGenerationPresenter.ts @@ -0,0 +1,28 @@ +import { RequestAvatarGenerationOutput } from '../dto/MediaDto'; +import type { IRequestAvatarGenerationPresenter, RequestAvatarGenerationResultDTO } from '@gridpilot/media/application/presenters/IRequestAvatarGenerationPresenter'; + +export class RequestAvatarGenerationPresenter implements IRequestAvatarGenerationPresenter { + private result: RequestAvatarGenerationOutput | null = null; + + reset() { + this.result = null; + } + + present(dto: RequestAvatarGenerationResultDTO) { + this.result = { + success: dto.status === 'completed', + requestId: dto.requestId, + avatarUrls: dto.avatarUrls, + errorMessage: dto.errorMessage, + }; + } + + get viewModel(): RequestAvatarGenerationOutput { + if (!this.result) throw new Error('Presenter not presented'); + return this.result; + } + + getViewModel(): RequestAvatarGenerationOutput { + return this.viewModel; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/payments/PaymentsProviders.ts b/apps/api/src/modules/payments/PaymentsProviders.ts index f8bf805b6..094e61c0c 100644 --- a/apps/api/src/modules/payments/PaymentsProviders.ts +++ b/apps/api/src/modules/payments/PaymentsProviders.ts @@ -1,39 +1,67 @@ import { Provider } from '@nestjs/common'; import { PaymentsService } from './PaymentsService'; -// Due to persistent module resolution issues in the environment, -// actual core interfaces and adapter implementations are not directly imported here. -// In a functional TypeScript environment, these would be imported as follows: -/* // Import core interfaces -import { IPaymentRepository } from 'core/payments/domain/repositories/IPaymentRepository'; -import { IMembershipFeeRepository } from 'core/payments/domain/repositories/IMembershipFeeRepository'; -import { IPrizeRepository } from 'core/payments/domain/repositories/IPrizeRepository'; -import { IWalletRepository } from 'core/payments/domain/repositories/IWalletRepository'; -import { IPaymentGateway } from 'core/payments/application/ports/IPaymentGateway'; -import { Logger } from '@gridpilot/shared/logging/Logger'; +import type { IPaymentRepository } from '@gridpilot/payments/domain/repositories/IPaymentRepository'; +import type { IMembershipFeeRepository, IMemberPaymentRepository } from '@gridpilot/payments/domain/repositories/IMembershipFeeRepository'; +import type { IPrizeRepository } from '@gridpilot/payments/domain/repositories/IPrizeRepository'; +import type { IWalletRepository, ITransactionRepository } from '@gridpilot/payments/domain/repositories/IWalletRepository'; +import type { Logger } from '@gridpilot/shared/application/Logger'; + +// Import use cases +import { GetPaymentsUseCase } from '@gridpilot/payments/application/use-cases/GetPaymentsUseCase'; +import { CreatePaymentUseCase } from '@gridpilot/payments/application/use-cases/CreatePaymentUseCase'; +import { UpdatePaymentStatusUseCase } from '@gridpilot/payments/application/use-cases/UpdatePaymentStatusUseCase'; +import { GetMembershipFeesUseCase } from '@gridpilot/payments/application/use-cases/GetMembershipFeesUseCase'; +import { UpsertMembershipFeeUseCase } from '@gridpilot/payments/application/use-cases/UpsertMembershipFeeUseCase'; +import { UpdateMemberPaymentUseCase } from '@gridpilot/payments/application/use-cases/UpdateMemberPaymentUseCase'; +import { GetPrizesUseCase } from '@gridpilot/payments/application/use-cases/GetPrizesUseCase'; +import { CreatePrizeUseCase } from '@gridpilot/payments/application/use-cases/CreatePrizeUseCase'; +import { AwardPrizeUseCase } from '@gridpilot/payments/application/use-cases/AwardPrizeUseCase'; +import { DeletePrizeUseCase } from '@gridpilot/payments/application/use-cases/DeletePrizeUseCase'; +import { GetWalletUseCase } from '@gridpilot/payments/application/use-cases/GetWalletUseCase'; +import { ProcessWalletTransactionUseCase } from '@gridpilot/payments/application/use-cases/ProcessWalletTransactionUseCase'; // Import concrete in-memory implementations import { InMemoryPaymentRepository } from 'adapters/payments/persistence/inmemory/InMemoryPaymentRepository'; -import { InMemoryMembershipFeeRepository } from 'adapters/payments/persistence/inmemory/InMemoryMembershipFeeRepository'; +import { InMemoryMembershipFeeRepository, InMemoryMemberPaymentRepository } from 'adapters/payments/persistence/inmemory/InMemoryMembershipFeeRepository'; import { InMemoryPrizeRepository } from 'adapters/payments/persistence/inmemory/InMemoryPrizeRepository'; -import { InMemoryWalletRepository } from 'adapters/payments/persistence/inmemory/InMemoryWalletRepository'; -import { InMemoryPaymentGateway } from 'adapters/payments/ports/InMemoryPaymentGateway'; +import { InMemoryWalletRepository, InMemoryTransactionRepository } from 'adapters/payments/persistence/inmemory/InMemoryWalletRepository'; import { ConsoleLogger } from 'adapters/logging/ConsoleLogger'; -*/ -// Define injection tokens as string literals for NestJS +// Repository injection tokens export const PAYMENT_REPOSITORY_TOKEN = 'IPaymentRepository'; export const MEMBERSHIP_FEE_REPOSITORY_TOKEN = 'IMembershipFeeRepository'; +export const MEMBER_PAYMENT_REPOSITORY_TOKEN = 'IMemberPaymentRepository'; export const PRIZE_REPOSITORY_TOKEN = 'IPrizeRepository'; export const WALLET_REPOSITORY_TOKEN = 'IWalletRepository'; -export const PAYMENT_GATEWAY_TOKEN = 'IPaymentGateway'; -export const LOGGER_TOKEN = 'Logger'; // Already defined in other Providers, but good to have here too +export const TRANSACTION_REPOSITORY_TOKEN = 'ITransactionRepository'; +export const LOGGER_TOKEN = 'Logger'; + +// Use case injection tokens +export const GET_PAYMENTS_USE_CASE_TOKEN = 'GetPaymentsUseCase'; +export const CREATE_PAYMENT_USE_CASE_TOKEN = 'CreatePaymentUseCase'; +export const UPDATE_PAYMENT_STATUS_USE_CASE_TOKEN = 'UpdatePaymentStatusUseCase'; +export const GET_MEMBERSHIP_FEES_USE_CASE_TOKEN = 'GetMembershipFeesUseCase'; +export const UPSERT_MEMBERSHIP_FEE_USE_CASE_TOKEN = 'UpsertMembershipFeeUseCase'; +export const UPDATE_MEMBER_PAYMENT_USE_CASE_TOKEN = 'UpdateMemberPaymentUseCase'; +export const GET_PRIZES_USE_CASE_TOKEN = 'GetPrizesUseCase'; +export const CREATE_PRIZE_USE_CASE_TOKEN = 'CreatePrizeUseCase'; +export const AWARD_PRIZE_USE_CASE_TOKEN = 'AwardPrizeUseCase'; +export const DELETE_PRIZE_USE_CASE_TOKEN = 'DeletePrizeUseCase'; +export const GET_WALLET_USE_CASE_TOKEN = 'GetWalletUseCase'; +export const PROCESS_WALLET_TRANSACTION_USE_CASE_TOKEN = 'ProcessWalletTransactionUseCase'; export const PaymentsProviders: Provider[] = [ - PaymentsService, // Provide the service itself - // In a functional setup, the following would be enabled: - /* + PaymentsService, + + // Logger + { + provide: LOGGER_TOKEN, + useClass: ConsoleLogger, + }, + + // Repositories (repositories are injected into use cases, NOT into services) { provide: PAYMENT_REPOSITORY_TOKEN, useFactory: (logger: Logger) => new InMemoryPaymentRepository(logger), @@ -44,6 +72,11 @@ export const PaymentsProviders: Provider[] = [ useFactory: (logger: Logger) => new InMemoryMembershipFeeRepository(logger), inject: [LOGGER_TOKEN], }, + { + provide: MEMBER_PAYMENT_REPOSITORY_TOKEN, + useFactory: (logger: Logger) => new InMemoryMemberPaymentRepository(logger), + inject: [LOGGER_TOKEN], + }, { provide: PRIZE_REPOSITORY_TOKEN, useFactory: (logger: Logger) => new InMemoryPrizeRepository(logger), @@ -55,13 +88,74 @@ export const PaymentsProviders: Provider[] = [ inject: [LOGGER_TOKEN], }, { - provide: PAYMENT_GATEWAY_TOKEN, - useFactory: (logger: Logger) => new InMemoryPaymentGateway(logger), + provide: TRANSACTION_REPOSITORY_TOKEN, + useFactory: (logger: Logger) => new InMemoryTransactionRepository(logger), inject: [LOGGER_TOKEN], }, + + // Use cases (use cases receive repositories, services receive use cases) { - provide: LOGGER_TOKEN, - useClass: ConsoleLogger, + provide: GET_PAYMENTS_USE_CASE_TOKEN, + useFactory: (paymentRepo: IPaymentRepository) => new GetPaymentsUseCase(paymentRepo), + inject: [PAYMENT_REPOSITORY_TOKEN], + }, + { + provide: CREATE_PAYMENT_USE_CASE_TOKEN, + useFactory: (paymentRepo: IPaymentRepository) => new CreatePaymentUseCase(paymentRepo), + inject: [PAYMENT_REPOSITORY_TOKEN], + }, + { + provide: UPDATE_PAYMENT_STATUS_USE_CASE_TOKEN, + useFactory: (paymentRepo: IPaymentRepository) => new UpdatePaymentStatusUseCase(paymentRepo), + inject: [PAYMENT_REPOSITORY_TOKEN], + }, + { + provide: GET_MEMBERSHIP_FEES_USE_CASE_TOKEN, + useFactory: (membershipFeeRepo: IMembershipFeeRepository, memberPaymentRepo: IMemberPaymentRepository) => + new GetMembershipFeesUseCase(membershipFeeRepo, memberPaymentRepo), + inject: [MEMBERSHIP_FEE_REPOSITORY_TOKEN, MEMBER_PAYMENT_REPOSITORY_TOKEN], + }, + { + provide: UPSERT_MEMBERSHIP_FEE_USE_CASE_TOKEN, + useFactory: (membershipFeeRepo: IMembershipFeeRepository) => new UpsertMembershipFeeUseCase(membershipFeeRepo), + inject: [MEMBERSHIP_FEE_REPOSITORY_TOKEN], + }, + { + provide: UPDATE_MEMBER_PAYMENT_USE_CASE_TOKEN, + useFactory: (membershipFeeRepo: IMembershipFeeRepository, memberPaymentRepo: IMemberPaymentRepository) => + new UpdateMemberPaymentUseCase(membershipFeeRepo, memberPaymentRepo), + inject: [MEMBERSHIP_FEE_REPOSITORY_TOKEN, MEMBER_PAYMENT_REPOSITORY_TOKEN], + }, + { + provide: GET_PRIZES_USE_CASE_TOKEN, + useFactory: (prizeRepo: IPrizeRepository) => new GetPrizesUseCase(prizeRepo), + inject: [PRIZE_REPOSITORY_TOKEN], + }, + { + provide: CREATE_PRIZE_USE_CASE_TOKEN, + useFactory: (prizeRepo: IPrizeRepository) => new CreatePrizeUseCase(prizeRepo), + inject: [PRIZE_REPOSITORY_TOKEN], + }, + { + provide: AWARD_PRIZE_USE_CASE_TOKEN, + useFactory: (prizeRepo: IPrizeRepository) => new AwardPrizeUseCase(prizeRepo), + inject: [PRIZE_REPOSITORY_TOKEN], + }, + { + provide: DELETE_PRIZE_USE_CASE_TOKEN, + useFactory: (prizeRepo: IPrizeRepository) => new DeletePrizeUseCase(prizeRepo), + inject: [PRIZE_REPOSITORY_TOKEN], + }, + { + provide: GET_WALLET_USE_CASE_TOKEN, + useFactory: (walletRepo: IWalletRepository, transactionRepo: ITransactionRepository) => + new GetWalletUseCase(walletRepo, transactionRepo), + inject: [WALLET_REPOSITORY_TOKEN, TRANSACTION_REPOSITORY_TOKEN], + }, + { + provide: PROCESS_WALLET_TRANSACTION_USE_CASE_TOKEN, + useFactory: (walletRepo: IWalletRepository, transactionRepo: ITransactionRepository) => + new ProcessWalletTransactionUseCase(walletRepo, transactionRepo), + inject: [WALLET_REPOSITORY_TOKEN, TRANSACTION_REPOSITORY_TOKEN], }, - */ ]; diff --git a/apps/api/src/modules/payments/PaymentsService.ts b/apps/api/src/modules/payments/PaymentsService.ts index 98266e48c..6a940c795 100644 --- a/apps/api/src/modules/payments/PaymentsService.ts +++ b/apps/api/src/modules/payments/PaymentsService.ts @@ -1,346 +1,190 @@ -import { Injectable } from '@nestjs/common'; -import { CreatePaymentInput, CreatePaymentOutput, UpdatePaymentStatusInput, UpdatePaymentStatusOutput, PaymentDto, GetPaymentsQuery, GetPaymentsOutput, PaymentStatus, MembershipFeeDto, MemberPaymentDto, GetMembershipFeesQuery, GetMembershipFeesOutput, UpsertMembershipFeeInput, UpsertMembershipFeeOutput, UpdateMemberPaymentInput, UpdateMemberPaymentOutput, MembershipFeeType, MemberPaymentStatus, PrizeDto, GetPrizesQuery, GetPrizesOutput, CreatePrizeInput, CreatePrizeOutput, AwardPrizeInput, AwardPrizeOutput, DeletePrizeInput, DeletePrizeOutput, PrizeType, WalletDto, TransactionDto, GetWalletQuery, GetWalletOutput, ProcessWalletTransactionInput, ProcessWalletTransactionOutput, TransactionType, ReferenceType } from './dto/PaymentsDto'; -import { LeagueSettingsDto, LeagueConfigFormModelStructureDto } from '../league/dto/LeagueDto'; // For the mock data definitions +import { Injectable, Inject } from '@nestjs/common'; +import type { Logger } from '@gridpilot/shared/application/Logger'; -const payments: Map = new Map(); -const membershipFees: Map = new Map(); -const memberPayments: Map = new Map(); -const prizes: Map = new Map(); -const wallets: Map = new Map(); -const transactions: Map = new Map(); +// Use cases +import type { GetPaymentsUseCase } from '@gridpilot/payments/application/use-cases/GetPaymentsUseCase'; +import type { CreatePaymentUseCase } from '@gridpilot/payments/application/use-cases/CreatePaymentUseCase'; +import type { UpdatePaymentStatusUseCase } from '@gridpilot/payments/application/use-cases/UpdatePaymentStatusUseCase'; +import type { GetMembershipFeesUseCase } from '@gridpilot/payments/application/use-cases/GetMembershipFeesUseCase'; +import type { UpsertMembershipFeeUseCase } from '@gridpilot/payments/application/use-cases/UpsertMembershipFeeUseCase'; +import type { UpdateMemberPaymentUseCase } from '@gridpilot/payments/application/use-cases/UpdateMemberPaymentUseCase'; +import type { GetPrizesUseCase } from '@gridpilot/payments/application/use-cases/GetPrizesUseCase'; +import type { CreatePrizeUseCase } from '@gridpilot/payments/application/use-cases/CreatePrizeUseCase'; +import type { AwardPrizeUseCase } from '@gridpilot/payments/application/use-cases/AwardPrizeUseCase'; +import type { DeletePrizeUseCase } from '@gridpilot/payments/application/use-cases/DeletePrizeUseCase'; +import type { GetWalletUseCase } from '@gridpilot/payments/application/use-cases/GetWalletUseCase'; +import type { ProcessWalletTransactionUseCase } from '@gridpilot/payments/application/use-cases/ProcessWalletTransactionUseCase'; -const PLATFORM_FEE_RATE = 0.10; +// Presenters +import { GetPaymentsPresenter } from './presenters/GetPaymentsPresenter'; +import { CreatePaymentPresenter } from './presenters/CreatePaymentPresenter'; +import { UpdatePaymentStatusPresenter } from './presenters/UpdatePaymentStatusPresenter'; +import { GetMembershipFeesPresenter } from './presenters/GetMembershipFeesPresenter'; +import { UpsertMembershipFeePresenter } from './presenters/UpsertMembershipFeePresenter'; +import { UpdateMemberPaymentPresenter } from './presenters/UpdateMemberPaymentPresenter'; +import { GetPrizesPresenter } from './presenters/GetPrizesPresenter'; +import { CreatePrizePresenter } from './presenters/CreatePrizePresenter'; +import { AwardPrizePresenter } from './presenters/AwardPrizePresenter'; +import { DeletePrizePresenter } from './presenters/DeletePrizePresenter'; +import { GetWalletPresenter } from './presenters/GetWalletPresenter'; +import { ProcessWalletTransactionPresenter } from './presenters/ProcessWalletTransactionPresenter'; + +// DTOs +import type { + CreatePaymentInput, + CreatePaymentOutput, + UpdatePaymentStatusInput, + UpdatePaymentStatusOutput, + GetPaymentsQuery, + GetPaymentsOutput, + GetMembershipFeesQuery, + GetMembershipFeesOutput, + UpsertMembershipFeeInput, + UpsertMembershipFeeOutput, + UpdateMemberPaymentInput, + UpdateMemberPaymentOutput, + GetPrizesQuery, + GetPrizesOutput, + CreatePrizeInput, + CreatePrizeOutput, + AwardPrizeInput, + AwardPrizeOutput, + DeletePrizeInput, + DeletePrizeOutput, + GetWalletQuery, + GetWalletOutput, + ProcessWalletTransactionInput, + ProcessWalletTransactionOutput, +} from './dto/PaymentsDto'; + +// Injection tokens +import { + GET_PAYMENTS_USE_CASE_TOKEN, + CREATE_PAYMENT_USE_CASE_TOKEN, + UPDATE_PAYMENT_STATUS_USE_CASE_TOKEN, + GET_MEMBERSHIP_FEES_USE_CASE_TOKEN, + UPSERT_MEMBERSHIP_FEE_USE_CASE_TOKEN, + UPDATE_MEMBER_PAYMENT_USE_CASE_TOKEN, + GET_PRIZES_USE_CASE_TOKEN, + CREATE_PRIZE_USE_CASE_TOKEN, + AWARD_PRIZE_USE_CASE_TOKEN, + DELETE_PRIZE_USE_CASE_TOKEN, + GET_WALLET_USE_CASE_TOKEN, + PROCESS_WALLET_TRANSACTION_USE_CASE_TOKEN, + LOGGER_TOKEN, +} from './PaymentsProviders'; @Injectable() export class PaymentsService { + constructor( + @Inject(GET_PAYMENTS_USE_CASE_TOKEN) private readonly getPaymentsUseCase: GetPaymentsUseCase, + @Inject(CREATE_PAYMENT_USE_CASE_TOKEN) private readonly createPaymentUseCase: CreatePaymentUseCase, + @Inject(UPDATE_PAYMENT_STATUS_USE_CASE_TOKEN) private readonly updatePaymentStatusUseCase: UpdatePaymentStatusUseCase, + @Inject(GET_MEMBERSHIP_FEES_USE_CASE_TOKEN) private readonly getMembershipFeesUseCase: GetMembershipFeesUseCase, + @Inject(UPSERT_MEMBERSHIP_FEE_USE_CASE_TOKEN) private readonly upsertMembershipFeeUseCase: UpsertMembershipFeeUseCase, + @Inject(UPDATE_MEMBER_PAYMENT_USE_CASE_TOKEN) private readonly updateMemberPaymentUseCase: UpdateMemberPaymentUseCase, + @Inject(GET_PRIZES_USE_CASE_TOKEN) private readonly getPrizesUseCase: GetPrizesUseCase, + @Inject(CREATE_PRIZE_USE_CASE_TOKEN) private readonly createPrizeUseCase: CreatePrizeUseCase, + @Inject(AWARD_PRIZE_USE_CASE_TOKEN) private readonly awardPrizeUseCase: AwardPrizeUseCase, + @Inject(DELETE_PRIZE_USE_CASE_TOKEN) private readonly deletePrizeUseCase: DeletePrizeUseCase, + @Inject(GET_WALLET_USE_CASE_TOKEN) private readonly getWalletUseCase: GetWalletUseCase, + @Inject(PROCESS_WALLET_TRANSACTION_USE_CASE_TOKEN) private readonly processWalletTransactionUseCase: ProcessWalletTransactionUseCase, + @Inject(LOGGER_TOKEN) private readonly logger: Logger, + ) {} + async getPayments(query: GetPaymentsQuery): Promise { - let results = Array.from(payments.values()); + this.logger.debug('[PaymentsService] Getting payments', { query }); - if (query.leagueId) { - results = results.filter(p => p.leagueId === query.leagueId); - } - if (query.payerId) { - results = results.filter(p => p.payerId === query.payerId); - } - if (query.type) { - results = results.filter(p => p.type === query.type); - } - - return { payments: results }; + const presenter = new GetPaymentsPresenter(); + await this.getPaymentsUseCase.execute(query, presenter); + return presenter.viewModel; } async createPayment(input: CreatePaymentInput): Promise { - const { type, amount, payerId, payerType, leagueId, seasonId } = input; + this.logger.debug('[PaymentsService] Creating payment', { input }); - const platformFee = amount * PLATFORM_FEE_RATE; - const netAmount = amount - platformFee; - - const id = `payment-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const payment: PaymentDto = { - id, - type, - amount, - platformFee, - netAmount, - payerId, - payerType, - leagueId, - seasonId: seasonId || undefined, - status: PaymentStatus.PENDING, - createdAt: new Date(), - }; - - payments.set(id, payment); - - return { payment }; + const presenter = new CreatePaymentPresenter(); + await this.createPaymentUseCase.execute(input, presenter); + return presenter.viewModel; } async updatePaymentStatus(input: UpdatePaymentStatusInput): Promise { - const { paymentId, status } = input; + this.logger.debug('[PaymentsService] Updating payment status', { input }); - const payment = payments.get(paymentId); - if (!payment) { - throw new Error('Payment not found'); - } - - payment.status = status; - if (status === PaymentStatus.COMPLETED) { - payment.completedAt = new Date(); - } - - payments.set(paymentId, payment); - - return { payment }; + const presenter = new UpdatePaymentStatusPresenter(); + await this.updatePaymentStatusUseCase.execute(input, presenter); + return presenter.viewModel; } async getMembershipFees(query: GetMembershipFeesQuery): Promise { - const { leagueId, driverId } = query; + this.logger.debug('[PaymentsService] Getting membership fees', { query }); - if (!leagueId) { - throw new Error('leagueId is required'); - } - - const fee = Array.from(membershipFees.values()).find(f => f.leagueId === leagueId) || null; - - let payments: MemberPaymentDto[] = []; - if (driverId) { - payments = Array.from(memberPayments.values()).filter( - p => membershipFees.get(p.feeId)?.leagueId === leagueId && p.driverId === driverId - ); - } - - return { fee, payments }; + const presenter = new GetMembershipFeesPresenter(); + await this.getMembershipFeesUseCase.execute(query, presenter); + return presenter.viewModel; } async upsertMembershipFee(input: UpsertMembershipFeeInput): Promise { - const { leagueId, seasonId, type, amount } = input; + this.logger.debug('[PaymentsService] Upserting membership fee', { input }); - // Check for existing fee config - let existingFee = Array.from(membershipFees.values()).find(f => f.leagueId === leagueId); - - if (existingFee) { - // Update existing fee - existingFee.type = type; - existingFee.amount = amount; - existingFee.seasonId = seasonId || existingFee.seasonId; - existingFee.enabled = amount > 0; - existingFee.updatedAt = new Date(); - membershipFees.set(existingFee.id, existingFee); - return { fee: existingFee }; - } - - const id = `fee-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const fee: MembershipFeeDto = { - id, - leagueId, - seasonId: seasonId || undefined, - type, - amount, - enabled: amount > 0, - createdAt: new Date(), - updatedAt: new Date(), - }; - - membershipFees.set(id, fee); - - return { fee }; + const presenter = new UpsertMembershipFeePresenter(); + await this.upsertMembershipFeeUseCase.execute(input, presenter); + return presenter.viewModel; } async updateMemberPayment(input: UpdateMemberPaymentInput): Promise { - const { feeId, driverId, status, paidAt } = input; + this.logger.debug('[PaymentsService] Updating member payment', { input }); - const fee = membershipFees.get(feeId); - if (!fee) { - throw new Error('Membership fee configuration not found'); - } - - // Find or create payment record - let payment = Array.from(memberPayments.values()).find( - p => p.feeId === feeId && p.driverId === driverId - ); - - if (!payment) { - const platformFee = fee.amount * PLATFORM_FEE_RATE; - const netAmount = fee.amount - platformFee; - - const paymentId = `mp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - payment = { - id: paymentId, - feeId, - driverId, - amount: fee.amount, - platformFee, - netAmount, - status: MemberPaymentStatus.PENDING, - dueDate: new Date(), - }; - memberPayments.set(paymentId, payment); - } - - if (status) { - payment.status = status; - } - if (paidAt || status === MemberPaymentStatus.PAID) { - payment.paidAt = paidAt ? new Date(paidAt) : new Date(); - } - - memberPayments.set(payment.id, payment); - - return { payment }; + const presenter = new UpdateMemberPaymentPresenter(); + await this.updateMemberPaymentUseCase.execute(input, presenter); + return presenter.viewModel; } async getPrizes(query: GetPrizesQuery): Promise { - const { leagueId, seasonId } = query; + this.logger.debug('[PaymentsService] Getting prizes', { query }); - let results = Array.from(prizes.values()).filter(p => p.leagueId === leagueId); - - if (seasonId) { - results = results.filter(p => p.seasonId === seasonId); - } - - results.sort((a, b) => a.position - b.position); - - return { prizes: results }; + const presenter = new GetPrizesPresenter(); + await this.getPrizesUseCase.execute({ leagueId: query.leagueId!, seasonId: query.seasonId }, presenter); + return presenter.viewModel; } async createPrize(input: CreatePrizeInput): Promise { - const { leagueId, seasonId, position, name, amount, type, description } = input; + this.logger.debug('[PaymentsService] Creating prize', { input }); - // Check for duplicate position - const existingPrize = Array.from(prizes.values()).find( - p => p.leagueId === leagueId && p.seasonId === seasonId && p.position === position - ); - - if (existingPrize) { - throw new Error(`Prize for position ${position} already exists`); - } - - const id = `prize-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const prize: PrizeDto = { - id, - leagueId, - seasonId, - position, - name, - amount, - type, - description: description || undefined, - awarded: false, - createdAt: new Date(), - }; - - prizes.set(id, prize); - - return { prize }; + const presenter = new CreatePrizePresenter(); + await this.createPrizeUseCase.execute(input, presenter); + return presenter.viewModel; } async awardPrize(input: AwardPrizeInput): Promise { - const { prizeId, driverId } = input; + this.logger.debug('[PaymentsService] Awarding prize', { input }); - const prize = prizes.get(prizeId); - if (!prize) { - throw new Error('Prize not found'); - } - - if (prize.awarded) { - throw new Error('Prize has already been awarded'); - } - - prize.awarded = true; - prize.awardedTo = driverId; - prize.awardedAt = new Date(); - - prizes.set(prizeId, prize); - - return { prize }; + const presenter = new AwardPrizePresenter(); + await this.awardPrizeUseCase.execute(input, presenter); + return presenter.viewModel; } async deletePrize(input: DeletePrizeInput): Promise { - const { prizeId } = input; + this.logger.debug('[PaymentsService] Deleting prize', { input }); - const prize = prizes.get(prizeId); - if (!prize) { - throw new Error('Prize not found'); - } - - if (prize.awarded) { - throw new Error('Cannot delete an awarded prize'); - } - - prizes.delete(prizeId); - - return { success: true }; + const presenter = new DeletePrizePresenter(); + await this.deletePrizeUseCase.execute(input, presenter); + return presenter.viewModel; } async getWallet(query: GetWalletQuery): Promise { - const { leagueId } = query; + this.logger.debug('[PaymentsService] Getting wallet', { query }); - if (!leagueId) { - throw new Error('LeagueId is required'); - } - - let wallet = Array.from(wallets.values()).find(w => w.leagueId === leagueId); - - if (!wallet) { - const id = `wallet-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - wallet = { - id, - leagueId, - balance: 0, - totalRevenue: 0, - totalPlatformFees: 0, - totalWithdrawn: 0, - createdAt: new Date(), - currency: 'USD', // Assuming default currency (mock) - }; - wallets.set(id, wallet); - } - - const walletTransactions = Array.from(transactions.values()) - .filter(t => t.walletId === wallet!.id) - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); - - return { wallet, transactions: walletTransactions }; + const presenter = new GetWalletPresenter(); + await this.getWalletUseCase.execute({ leagueId: query.leagueId! }, presenter); + return presenter.viewModel; } async processWalletTransaction(input: ProcessWalletTransactionInput): Promise { - const { leagueId, type, amount, description, referenceId, referenceType } = input; + this.logger.debug('[PaymentsService] Processing wallet transaction', { input }); - if (!leagueId || !type || amount === undefined || !description) { - throw new Error('Missing required fields: leagueId, type, amount, description'); - } - - if (type !== TransactionType.DEPOSIT && type !== TransactionType.WITHDRAWAL) { - throw new Error('Type must be "deposit" or "withdrawal"'); - } - - let wallet = Array.from(wallets.values()).find(w => w.leagueId === leagueId); - - if (!wallet) { - const id = `wallet-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - wallet = { - id, - leagueId, - balance: 0, - totalRevenue: 0, - totalPlatformFees: 0, - totalWithdrawn: 0, - createdAt: new Date(), - currency: 'USD', // Assuming default currency (mock) - }; - wallets.set(id, wallet); - } - - if (type === TransactionType.WITHDRAWAL) { - if (amount > wallet.balance) { - throw new Error('Insufficient balance'); - } - } - - const transactionId = `txn-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const transaction: TransactionDto = { - id: transactionId, - walletId: wallet.id, - type, - amount, - description, - referenceId: referenceId || undefined, - referenceType: referenceType || undefined, - createdAt: new Date(), - }; - - transactions.set(transactionId, transaction); - - if (type === TransactionType.DEPOSIT) { - wallet.balance += amount; - wallet.totalRevenue += amount; - } else { - wallet.balance -= amount; - wallet.totalWithdrawn += amount; - } - - wallets.set(wallet.id, wallet); - - return { wallet, transaction }; + const presenter = new ProcessWalletTransactionPresenter(); + await this.processWalletTransactionUseCase.execute(input, presenter); + return presenter.viewModel; } } diff --git a/apps/api/src/modules/payments/presenters/AwardPrizePresenter.ts b/apps/api/src/modules/payments/presenters/AwardPrizePresenter.ts new file mode 100644 index 000000000..b113d6df5 --- /dev/null +++ b/apps/api/src/modules/payments/presenters/AwardPrizePresenter.ts @@ -0,0 +1,26 @@ +import type { + IAwardPrizePresenter, + AwardPrizeResultDTO, + AwardPrizeViewModel, +} from '@gridpilot/payments/application/presenters/IAwardPrizePresenter'; + +export class AwardPrizePresenter implements IAwardPrizePresenter { + private result: AwardPrizeViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: AwardPrizeResultDTO) { + this.result = dto; + } + + getViewModel(): AwardPrizeViewModel | null { + return this.result; + } + + get viewModel(): AwardPrizeViewModel { + if (!this.result) throw new Error('Presenter not presented'); + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/payments/presenters/CreatePaymentPresenter.ts b/apps/api/src/modules/payments/presenters/CreatePaymentPresenter.ts new file mode 100644 index 000000000..73a98fa5a --- /dev/null +++ b/apps/api/src/modules/payments/presenters/CreatePaymentPresenter.ts @@ -0,0 +1,26 @@ +import type { + ICreatePaymentPresenter, + CreatePaymentResultDTO, + CreatePaymentViewModel, +} from '@gridpilot/payments/application/presenters/ICreatePaymentPresenter'; + +export class CreatePaymentPresenter implements ICreatePaymentPresenter { + private result: CreatePaymentViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: CreatePaymentResultDTO) { + this.result = dto; + } + + getViewModel(): CreatePaymentViewModel | null { + return this.result; + } + + get viewModel(): CreatePaymentViewModel { + if (!this.result) throw new Error('Presenter not presented'); + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/payments/presenters/CreatePrizePresenter.ts b/apps/api/src/modules/payments/presenters/CreatePrizePresenter.ts new file mode 100644 index 000000000..c1e0105bb --- /dev/null +++ b/apps/api/src/modules/payments/presenters/CreatePrizePresenter.ts @@ -0,0 +1,26 @@ +import type { + ICreatePrizePresenter, + CreatePrizeResultDTO, + CreatePrizeViewModel, +} from '@gridpilot/payments/application/presenters/ICreatePrizePresenter'; + +export class CreatePrizePresenter implements ICreatePrizePresenter { + private result: CreatePrizeViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: CreatePrizeResultDTO) { + this.result = dto; + } + + getViewModel(): CreatePrizeViewModel | null { + return this.result; + } + + get viewModel(): CreatePrizeViewModel { + if (!this.result) throw new Error('Presenter not presented'); + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/payments/presenters/DeletePrizePresenter.ts b/apps/api/src/modules/payments/presenters/DeletePrizePresenter.ts new file mode 100644 index 000000000..a6abc3d1b --- /dev/null +++ b/apps/api/src/modules/payments/presenters/DeletePrizePresenter.ts @@ -0,0 +1,26 @@ +import type { + IDeletePrizePresenter, + DeletePrizeResultDTO, + DeletePrizeViewModel, +} from '@gridpilot/payments/application/presenters/IDeletePrizePresenter'; + +export class DeletePrizePresenter implements IDeletePrizePresenter { + private result: DeletePrizeViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: DeletePrizeResultDTO) { + this.result = dto; + } + + getViewModel(): DeletePrizeViewModel | null { + return this.result; + } + + get viewModel(): DeletePrizeViewModel { + if (!this.result) throw new Error('Presenter not presented'); + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/payments/presenters/GetMembershipFeesPresenter.ts b/apps/api/src/modules/payments/presenters/GetMembershipFeesPresenter.ts new file mode 100644 index 000000000..2010d8fa9 --- /dev/null +++ b/apps/api/src/modules/payments/presenters/GetMembershipFeesPresenter.ts @@ -0,0 +1,26 @@ +import type { + IGetMembershipFeesPresenter, + GetMembershipFeesResultDTO, + GetMembershipFeesViewModel, +} from '@gridpilot/payments/application/presenters/IGetMembershipFeesPresenter'; + +export class GetMembershipFeesPresenter implements IGetMembershipFeesPresenter { + private result: GetMembershipFeesViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: GetMembershipFeesResultDTO) { + this.result = dto; + } + + getViewModel(): GetMembershipFeesViewModel | null { + return this.result; + } + + get viewModel(): GetMembershipFeesViewModel { + if (!this.result) throw new Error('Presenter not presented'); + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/payments/presenters/GetPaymentsPresenter.ts b/apps/api/src/modules/payments/presenters/GetPaymentsPresenter.ts new file mode 100644 index 000000000..9619d2331 --- /dev/null +++ b/apps/api/src/modules/payments/presenters/GetPaymentsPresenter.ts @@ -0,0 +1,26 @@ +import type { + IGetPaymentsPresenter, + GetPaymentsResultDTO, + GetPaymentsViewModel, +} from '@gridpilot/payments/application/presenters/IGetPaymentsPresenter'; + +export class GetPaymentsPresenter implements IGetPaymentsPresenter { + private result: GetPaymentsViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: GetPaymentsResultDTO) { + this.result = dto; + } + + getViewModel(): GetPaymentsViewModel | null { + return this.result; + } + + get viewModel(): GetPaymentsViewModel { + if (!this.result) throw new Error('Presenter not presented'); + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/payments/presenters/GetPrizesPresenter.ts b/apps/api/src/modules/payments/presenters/GetPrizesPresenter.ts new file mode 100644 index 000000000..05fb4c3fb --- /dev/null +++ b/apps/api/src/modules/payments/presenters/GetPrizesPresenter.ts @@ -0,0 +1,26 @@ +import type { + IGetPrizesPresenter, + GetPrizesResultDTO, + GetPrizesViewModel, +} from '@gridpilot/payments/application/presenters/IGetPrizesPresenter'; + +export class GetPrizesPresenter implements IGetPrizesPresenter { + private result: GetPrizesViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: GetPrizesResultDTO) { + this.result = dto; + } + + getViewModel(): GetPrizesViewModel | null { + return this.result; + } + + get viewModel(): GetPrizesViewModel { + if (!this.result) throw new Error('Presenter not presented'); + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/payments/presenters/GetWalletPresenter.ts b/apps/api/src/modules/payments/presenters/GetWalletPresenter.ts new file mode 100644 index 000000000..016b526f3 --- /dev/null +++ b/apps/api/src/modules/payments/presenters/GetWalletPresenter.ts @@ -0,0 +1,26 @@ +import type { + IGetWalletPresenter, + GetWalletResultDTO, + GetWalletViewModel, +} from '@gridpilot/payments/application/presenters/IGetWalletPresenter'; + +export class GetWalletPresenter implements IGetWalletPresenter { + private result: GetWalletViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: GetWalletResultDTO) { + this.result = dto; + } + + getViewModel(): GetWalletViewModel | null { + return this.result; + } + + get viewModel(): GetWalletViewModel { + if (!this.result) throw new Error('Presenter not presented'); + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/payments/presenters/ProcessWalletTransactionPresenter.ts b/apps/api/src/modules/payments/presenters/ProcessWalletTransactionPresenter.ts new file mode 100644 index 000000000..004cc4eeb --- /dev/null +++ b/apps/api/src/modules/payments/presenters/ProcessWalletTransactionPresenter.ts @@ -0,0 +1,26 @@ +import type { + IProcessWalletTransactionPresenter, + ProcessWalletTransactionResultDTO, + ProcessWalletTransactionViewModel, +} from '@gridpilot/payments/application/presenters/IProcessWalletTransactionPresenter'; + +export class ProcessWalletTransactionPresenter implements IProcessWalletTransactionPresenter { + private result: ProcessWalletTransactionViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: ProcessWalletTransactionResultDTO) { + this.result = dto; + } + + getViewModel(): ProcessWalletTransactionViewModel | null { + return this.result; + } + + get viewModel(): ProcessWalletTransactionViewModel { + if (!this.result) throw new Error('Presenter not presented'); + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/payments/presenters/UpdateMemberPaymentPresenter.ts b/apps/api/src/modules/payments/presenters/UpdateMemberPaymentPresenter.ts new file mode 100644 index 000000000..d8173c3b0 --- /dev/null +++ b/apps/api/src/modules/payments/presenters/UpdateMemberPaymentPresenter.ts @@ -0,0 +1,26 @@ +import type { + IUpdateMemberPaymentPresenter, + UpdateMemberPaymentResultDTO, + UpdateMemberPaymentViewModel, +} from '@gridpilot/payments/application/presenters/IUpdateMemberPaymentPresenter'; + +export class UpdateMemberPaymentPresenter implements IUpdateMemberPaymentPresenter { + private result: UpdateMemberPaymentViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: UpdateMemberPaymentResultDTO) { + this.result = dto; + } + + getViewModel(): UpdateMemberPaymentViewModel | null { + return this.result; + } + + get viewModel(): UpdateMemberPaymentViewModel { + if (!this.result) throw new Error('Presenter not presented'); + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/payments/presenters/UpdatePaymentStatusPresenter.ts b/apps/api/src/modules/payments/presenters/UpdatePaymentStatusPresenter.ts new file mode 100644 index 000000000..1b386c962 --- /dev/null +++ b/apps/api/src/modules/payments/presenters/UpdatePaymentStatusPresenter.ts @@ -0,0 +1,26 @@ +import type { + IUpdatePaymentStatusPresenter, + UpdatePaymentStatusResultDTO, + UpdatePaymentStatusViewModel, +} from '@gridpilot/payments/application/presenters/IUpdatePaymentStatusPresenter'; + +export class UpdatePaymentStatusPresenter implements IUpdatePaymentStatusPresenter { + private result: UpdatePaymentStatusViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: UpdatePaymentStatusResultDTO) { + this.result = dto; + } + + getViewModel(): UpdatePaymentStatusViewModel | null { + return this.result; + } + + get viewModel(): UpdatePaymentStatusViewModel { + if (!this.result) throw new Error('Presenter not presented'); + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/payments/presenters/UpsertMembershipFeePresenter.ts b/apps/api/src/modules/payments/presenters/UpsertMembershipFeePresenter.ts new file mode 100644 index 000000000..aa205e1b8 --- /dev/null +++ b/apps/api/src/modules/payments/presenters/UpsertMembershipFeePresenter.ts @@ -0,0 +1,26 @@ +import type { + IUpsertMembershipFeePresenter, + UpsertMembershipFeeResultDTO, + UpsertMembershipFeeViewModel, +} from '@gridpilot/payments/application/presenters/IUpsertMembershipFeePresenter'; + +export class UpsertMembershipFeePresenter implements IUpsertMembershipFeePresenter { + private result: UpsertMembershipFeeViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: UpsertMembershipFeeResultDTO) { + this.result = dto; + } + + getViewModel(): UpsertMembershipFeeViewModel | null { + return this.result; + } + + get viewModel(): UpsertMembershipFeeViewModel { + if (!this.result) throw new Error('Presenter not presented'); + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/payments/presenters/index.ts b/apps/api/src/modules/payments/presenters/index.ts new file mode 100644 index 000000000..65455ad76 --- /dev/null +++ b/apps/api/src/modules/payments/presenters/index.ts @@ -0,0 +1,12 @@ +export * from './GetPaymentsPresenter'; +export * from './CreatePaymentPresenter'; +export * from './UpdatePaymentStatusPresenter'; +export * from './GetMembershipFeesPresenter'; +export * from './UpsertMembershipFeePresenter'; +export * from './UpdateMemberPaymentPresenter'; +export * from './GetPrizesPresenter'; +export * from './CreatePrizePresenter'; +export * from './AwardPrizePresenter'; +export * from './DeletePrizePresenter'; +export * from './GetWalletPresenter'; +export * from './ProcessWalletTransactionPresenter'; \ No newline at end of file diff --git a/apps/api/src/modules/race/RaceProviders.ts b/apps/api/src/modules/race/RaceProviders.ts index 86ff8f392..fa547f7dc 100644 --- a/apps/api/src/modules/race/RaceProviders.ts +++ b/apps/api/src/modules/race/RaceProviders.ts @@ -1,18 +1,52 @@ import { Provider } from '@nestjs/common'; import { RaceService } from './RaceService'; +// Import core interfaces +import { Logger } from '@gridpilot/shared/application/Logger'; +import { IRaceRepository } from '@gridpilot/racing/domain/repositories/IRaceRepository'; +import { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository'; + +// Import concrete in-memory implementations +import { InMemoryRaceRepository } from 'adapters/racing/persistence/inmemory/InMemoryRaceRepository'; +import { InMemoryLeagueRepository } from 'adapters/racing/persistence/inmemory/InMemoryLeagueRepository'; +import { ConsoleLogger } from 'adapters/logging/ConsoleLogger'; + +// Import use cases +import { GetAllRacesUseCase } from '@gridpilot/racing/application/use-cases/GetAllRacesUseCase'; +import { GetTotalRacesUseCase } from '@gridpilot/racing/application/use-cases/GetTotalRacesUseCase'; +import { ImportRaceResultsApiUseCase } from '@gridpilot/racing/application/use-cases/ImportRaceResultsApiUseCase'; + +// Define injection tokens +export const RACE_REPOSITORY_TOKEN = 'IRaceRepository'; +export const LEAGUE_REPOSITORY_TOKEN = 'ILeagueRepository'; +export const LOGGER_TOKEN = 'Logger'; + export const RaceProviders: Provider[] = [ RaceService, - // In a functional setup, other providers would be here, e.g.: - /* { - provide: 'Logger', + provide: RACE_REPOSITORY_TOKEN, + useFactory: (logger: Logger) => new InMemoryRaceRepository(logger), + inject: [LOGGER_TOKEN], + }, + { + provide: LEAGUE_REPOSITORY_TOKEN, + useFactory: (logger: Logger) => new InMemoryLeagueRepository(logger), + inject: [LOGGER_TOKEN], + }, + { + provide: LOGGER_TOKEN, useClass: ConsoleLogger, }, + // Use cases { - provide: 'IRaceRepository', - useClass: InMemoryRaceRepository, + provide: GetAllRacesUseCase, + useFactory: (raceRepo: IRaceRepository, leagueRepo: ILeagueRepository) => new GetAllRacesUseCase(raceRepo, leagueRepo), + inject: [RACE_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN], }, - // ... other providers - */ + { + provide: GetTotalRacesUseCase, + useFactory: (raceRepo: IRaceRepository) => new GetTotalRacesUseCase(raceRepo), + inject: [RACE_REPOSITORY_TOKEN], + }, + ImportRaceResultsApiUseCase, ]; diff --git a/apps/api/src/modules/race/RaceService.ts b/apps/api/src/modules/race/RaceService.ts index 6dc3a3740..0b278efb0 100644 --- a/apps/api/src/modules/race/RaceService.ts +++ b/apps/api/src/modules/race/RaceService.ts @@ -1,37 +1,50 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Inject } from '@nestjs/common'; import { AllRacesPageViewModel, RaceStatsDto, ImportRaceResultsInput, ImportRaceResultsSummaryViewModel } from './dto/RaceDto'; +// Core imports +import { Logger } from '@gridpilot/shared/application/Logger'; + +// Use cases +import { GetAllRacesUseCase } from '@gridpilot/racing/application/use-cases/GetAllRacesUseCase'; +import { GetTotalRacesUseCase } from '@gridpilot/racing/application/use-cases/GetTotalRacesUseCase'; +import { ImportRaceResultsApiUseCase } from '@gridpilot/racing/application/use-cases/ImportRaceResultsApiUseCase'; + +// Presenters +import { GetAllRacesPresenter } from './presenters/GetAllRacesPresenter'; +import { GetTotalRacesPresenter } from './presenters/GetTotalRacesPresenter'; +import { ImportRaceResultsApiPresenter } from './presenters/ImportRaceResultsApiPresenter'; + +// Tokens +import { LOGGER_TOKEN } from './RaceProviders'; + @Injectable() export class RaceService { + constructor( + private readonly getAllRacesUseCase: GetAllRacesUseCase, + private readonly getTotalRacesUseCase: GetTotalRacesUseCase, + private readonly importRaceResultsApiUseCase: ImportRaceResultsApiUseCase, + @Inject(LOGGER_TOKEN) private readonly logger: Logger, + ) {} - constructor() {} + async getAllRaces(): Promise { + this.logger.debug('[RaceService] Fetching all races.'); - getAllRaces(): Promise { - console.log('[RaceService] Returning mock all races.'); - return Promise.resolve({ - races: [ - { id: 'race-1', name: 'Global Race 1', date: new Date().toISOString(), leagueName: 'Global Racing' }, - { id: 'race-2', name: 'Amateur Race 1', date: new Date().toISOString(), leagueName: 'Amateur Series' }, - ], - totalCount: 2, - }); + const presenter = new GetAllRacesPresenter(); + await this.getAllRacesUseCase.execute({}, presenter); + return presenter.getViewModel()!; } - getTotalRaces(): Promise { - console.log('[RaceService] Returning mock total races.'); - return Promise.resolve({ - totalRaces: 2, // Placeholder - }); + async getTotalRaces(): Promise { + this.logger.debug('[RaceService] Fetching total races count.'); + const presenter = new GetTotalRacesPresenter(); + await this.getTotalRacesUseCase.execute({}, presenter); + return presenter.getViewModel()!; } async importRaceResults(input: ImportRaceResultsInput): Promise { - console.log('Importing race results:', input); - return { - success: true, - raceId: input.raceId, - driversProcessed: 10, // Mock data - resultsRecorded: 10, // Mock data - errors: [], - }; + this.logger.debug('Importing race results:', input); + const presenter = new ImportRaceResultsApiPresenter(); + await this.importRaceResultsApiUseCase.execute({ raceId: input.raceId, resultsFileContent: input.resultsFileContent }, presenter); + return presenter.getViewModel()!; } } diff --git a/apps/api/src/modules/race/presenters/GetAllRacesPresenter.ts b/apps/api/src/modules/race/presenters/GetAllRacesPresenter.ts new file mode 100644 index 000000000..effb534ba --- /dev/null +++ b/apps/api/src/modules/race/presenters/GetAllRacesPresenter.ts @@ -0,0 +1,17 @@ +import { IGetAllRacesPresenter, GetAllRacesResultDTO, AllRacesPageViewModel } from '@gridpilot/racing/application/presenters/IGetAllRacesPresenter'; + +export class GetAllRacesPresenter implements IGetAllRacesPresenter { + private result: AllRacesPageViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: GetAllRacesResultDTO) { + this.result = dto; + } + + getViewModel(): AllRacesPageViewModel | null { + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/race/presenters/GetTotalRacesPresenter.ts b/apps/api/src/modules/race/presenters/GetTotalRacesPresenter.ts new file mode 100644 index 000000000..3c28a3d90 --- /dev/null +++ b/apps/api/src/modules/race/presenters/GetTotalRacesPresenter.ts @@ -0,0 +1,20 @@ +import { IGetTotalRacesPresenter, GetTotalRacesResultDTO } from '@gridpilot/racing/application/presenters/IGetTotalRacesPresenter'; +import { RaceStatsDto } from '../dto/RaceDto'; + +export class GetTotalRacesPresenter implements IGetTotalRacesPresenter { + private result: RaceStatsDto | null = null; + + reset() { + this.result = null; + } + + present(dto: GetTotalRacesResultDTO) { + this.result = { + totalRaces: dto.totalRaces, + }; + } + + getViewModel(): RaceStatsDto | null { + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/race/presenters/ImportRaceResultsApiPresenter.ts b/apps/api/src/modules/race/presenters/ImportRaceResultsApiPresenter.ts new file mode 100644 index 000000000..d214e6e2a --- /dev/null +++ b/apps/api/src/modules/race/presenters/ImportRaceResultsApiPresenter.ts @@ -0,0 +1,17 @@ +import { IImportRaceResultsApiPresenter, ImportRaceResultsApiResultDTO, ImportRaceResultsSummaryViewModel } from '@gridpilot/racing/application/presenters/IImportRaceResultsApiPresenter'; + +export class ImportRaceResultsApiPresenter implements IImportRaceResultsApiPresenter { + private result: ImportRaceResultsSummaryViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: ImportRaceResultsApiResultDTO) { + this.result = dto; + } + + getViewModel(): ImportRaceResultsSummaryViewModel | null { + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/sponsor/SponsorProviders.ts b/apps/api/src/modules/sponsor/SponsorProviders.ts index b0298552d..c1d32dadf 100644 --- a/apps/api/src/modules/sponsor/SponsorProviders.ts +++ b/apps/api/src/modules/sponsor/SponsorProviders.ts @@ -1,5 +1,134 @@ +import { Provider } from '@nestjs/common'; import { SponsorService } from './SponsorService'; -export const SponsorProviders = [ +// Import core interfaces +import { ISponsorRepository } from '@gridpilot/racing/domain/repositories/ISponsorRepository'; +import { ISeasonSponsorshipRepository } from '@gridpilot/racing/domain/repositories/ISeasonSponsorshipRepository'; +import { ISeasonRepository } from '@gridpilot/racing/domain/repositories/ISeasonRepository'; +import { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository'; +import { ILeagueMembershipRepository } from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository'; +import { IRaceRepository } from '@gridpilot/racing/domain/repositories/IRaceRepository'; +import { ISponsorshipPricingRepository } from '@gridpilot/racing/domain/repositories/ISponsorshipPricingRepository'; +import { ISponsorshipRequestRepository } from '@gridpilot/racing/domain/repositories/ISponsorshipRequestRepository'; +import { Logger } from '@gridpilot/shared/application'; + +// Import use cases +import { GetSponsorshipPricingUseCase } from '@gridpilot/racing/application/use-cases/GetSponsorshipPricingUseCase'; +import { GetSponsorsUseCase } from '@gridpilot/racing/application/use-cases/GetSponsorsUseCase'; +import { CreateSponsorUseCase } from '@gridpilot/racing/application/use-cases/CreateSponsorUseCase'; +import { GetSponsorDashboardUseCase } from '@gridpilot/racing/application/use-cases/GetSponsorDashboardUseCase'; +import { GetSponsorSponsorshipsUseCase } from '@gridpilot/racing/application/use-cases/GetSponsorSponsorshipsUseCase'; +import { GetEntitySponsorshipPricingUseCase } from '@gridpilot/racing/application/use-cases/GetEntitySponsorshipPricingUseCase'; + +// Import concrete in-memory implementations +import { InMemorySponsorRepository } from 'adapters/racing/persistence/inmemory/InMemorySponsorRepository'; +import { InMemorySeasonSponsorshipRepository } from 'adapters/racing/persistence/inmemory/InMemorySeasonSponsorshipRepository'; +import { InMemorySeasonRepository } from 'adapters/racing/persistence/inmemory/InMemorySeasonRepository'; +import { InMemoryLeagueRepository } from 'adapters/racing/persistence/inmemory/InMemoryLeagueRepository'; +import { InMemoryLeagueMembershipRepository } from 'adapters/racing/persistence/inmemory/InMemoryLeagueMembershipRepository'; +import { InMemoryRaceRepository } from 'adapters/racing/persistence/inmemory/InMemoryRaceRepository'; +import { InMemorySponsorshipPricingRepository } from 'adapters/racing/persistence/inmemory/InMemorySponsorshipPricingRepository'; +import { InMemorySponsorshipRequestRepository } from 'adapters/racing/persistence/inmemory/InMemorySponsorshipRequestRepository'; +import { ConsoleLogger } from 'adapters/logging/ConsoleLogger'; + +// Define injection tokens +export const SPONSOR_REPOSITORY_TOKEN = 'ISponsorRepository'; +export const SEASON_SPONSORSHIP_REPOSITORY_TOKEN = 'ISeasonSponsorshipRepository'; +export const SEASON_REPOSITORY_TOKEN = 'ISeasonRepository'; +export const LEAGUE_REPOSITORY_TOKEN = 'ILeagueRepository'; +export const LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN = 'ILeagueMembershipRepository'; +export const RACE_REPOSITORY_TOKEN = 'IRaceRepository'; +export const SPONSORSHIP_PRICING_REPOSITORY_TOKEN = 'ISponsorshipPricingRepository'; +export const SPONSORSHIP_REQUEST_REPOSITORY_TOKEN = 'ISponsorshipRequestRepository'; +export const LOGGER_TOKEN = 'Logger'; + +// Use case tokens +export const GET_SPONSORSHIP_PRICING_USE_CASE_TOKEN = 'GetSponsorshipPricingUseCase'; +export const GET_SPONSORS_USE_CASE_TOKEN = 'GetSponsorsUseCase'; +export const CREATE_SPONSOR_USE_CASE_TOKEN = 'CreateSponsorUseCase'; +export const GET_SPONSOR_DASHBOARD_USE_CASE_TOKEN = 'GetSponsorDashboardUseCase'; +export const GET_SPONSOR_SPONSORSHIPS_USE_CASE_TOKEN = 'GetSponsorSponsorshipsUseCase'; +export const GET_ENTITY_SPONSORSHIP_PRICING_USE_CASE_TOKEN = 'GetEntitySponsorshipPricingUseCase'; + +export const SponsorProviders: Provider[] = [ SponsorService, + // Repositories + { + provide: SPONSOR_REPOSITORY_TOKEN, + useFactory: (logger: Logger) => new InMemorySponsorRepository(logger), + inject: [LOGGER_TOKEN], + }, + { + provide: SEASON_SPONSORSHIP_REPOSITORY_TOKEN, + useFactory: (logger: Logger) => new InMemorySeasonSponsorshipRepository(logger), + inject: [LOGGER_TOKEN], + }, + { + provide: SEASON_REPOSITORY_TOKEN, + useFactory: (logger: Logger) => new InMemorySeasonRepository(logger), + inject: [LOGGER_TOKEN], + }, + { + provide: LEAGUE_REPOSITORY_TOKEN, + useFactory: (logger: Logger) => new InMemoryLeagueRepository(logger), + inject: [LOGGER_TOKEN], + }, + { + provide: LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, + useFactory: (logger: Logger) => new InMemoryLeagueMembershipRepository(logger), + inject: [LOGGER_TOKEN], + }, + { + provide: RACE_REPOSITORY_TOKEN, + useFactory: (logger: Logger) => new InMemoryRaceRepository(logger), + inject: [LOGGER_TOKEN], + }, + { + provide: SPONSORSHIP_PRICING_REPOSITORY_TOKEN, + useFactory: (logger: Logger) => new InMemorySponsorshipPricingRepository(logger), + inject: [LOGGER_TOKEN], + }, + { + provide: SPONSORSHIP_REQUEST_REPOSITORY_TOKEN, + useFactory: (logger: Logger) => new InMemorySponsorshipRequestRepository(logger), + inject: [LOGGER_TOKEN], + }, + { + provide: LOGGER_TOKEN, + useClass: ConsoleLogger, + }, + // Use cases + { + provide: GET_SPONSORSHIP_PRICING_USE_CASE_TOKEN, + useFactory: () => new GetSponsorshipPricingUseCase(), + inject: [], + }, + { + provide: GET_SPONSORS_USE_CASE_TOKEN, + useFactory: (sponsorRepo: ISponsorRepository) => new GetSponsorsUseCase(sponsorRepo), + inject: [SPONSOR_REPOSITORY_TOKEN], + }, + { + provide: CREATE_SPONSOR_USE_CASE_TOKEN, + useFactory: (sponsorRepo: ISponsorRepository) => new CreateSponsorUseCase(sponsorRepo), + inject: [SPONSOR_REPOSITORY_TOKEN], + }, + { + provide: GET_SPONSOR_DASHBOARD_USE_CASE_TOKEN, + useFactory: (sponsorRepo: ISponsorRepository, seasonSponsorshipRepo: ISeasonSponsorshipRepository, seasonRepo: ISeasonRepository, leagueRepo: ILeagueRepository, leagueMembershipRepo: ILeagueMembershipRepository, raceRepo: IRaceRepository) => + new GetSponsorDashboardUseCase(sponsorRepo, seasonSponsorshipRepo, seasonRepo, leagueRepo, leagueMembershipRepo, raceRepo), + inject: [SPONSOR_REPOSITORY_TOKEN, SEASON_SPONSORSHIP_REPOSITORY_TOKEN, SEASON_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN], + }, + { + provide: GET_SPONSOR_SPONSORSHIPS_USE_CASE_TOKEN, + useFactory: (sponsorRepo: ISponsorRepository, seasonSponsorshipRepo: ISeasonSponsorshipRepository, seasonRepo: ISeasonRepository, raceRepo: IRaceRepository) => + new GetSponsorSponsorshipsUseCase(sponsorRepo, seasonSponsorshipRepo, seasonRepo, raceRepo), + inject: [SPONSOR_REPOSITORY_TOKEN, SEASON_SPONSORSHIP_REPOSITORY_TOKEN, SEASON_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN], + }, + { + provide: GET_ENTITY_SPONSORSHIP_PRICING_USE_CASE_TOKEN, + useFactory: (sponsorshipPricingRepo: ISponsorshipPricingRepository, sponsorshipRequestRepo: ISponsorshipRequestRepository, seasonSponsorshipRepo: ISeasonSponsorshipRepository, logger: Logger) => + new GetEntitySponsorshipPricingUseCase(sponsorshipPricingRepo, sponsorshipRequestRepo, seasonSponsorshipRepo, logger), + inject: [SPONSORSHIP_PRICING_REPOSITORY_TOKEN, SPONSORSHIP_REQUEST_REPOSITORY_TOKEN, SEASON_SPONSORSHIP_REPOSITORY_TOKEN, LOGGER_TOKEN], + }, ]; diff --git a/apps/api/src/modules/sponsor/SponsorService.ts b/apps/api/src/modules/sponsor/SponsorService.ts index 02937313f..a0684365a 100644 --- a/apps/api/src/modules/sponsor/SponsorService.ts +++ b/apps/api/src/modules/sponsor/SponsorService.ts @@ -1,162 +1,72 @@ -import { Injectable } from '@nestjs/common'; -import { GetEntitySponsorshipPricingResultDto, SponsorDto, GetSponsorsOutput, CreateSponsorInput, CreateSponsorOutput, GetSponsorDashboardQueryParams, SponsorDashboardDTO, GetSponsorSponsorshipsQueryParams, SponsorshipDetailDTO, SponsorSponsorshipsDTO, SponsoredLeagueDTO, SponsorDashboardMetricsDTO, SponsorDashboardInvestmentDTO } from './dto/SponsorDto'; +import { Injectable, Inject } from '@nestjs/common'; +import { GetEntitySponsorshipPricingResultDto, GetSponsorsOutput, CreateSponsorInput, CreateSponsorOutput, GetSponsorDashboardQueryParams, SponsorDashboardDTO, GetSponsorSponsorshipsQueryParams, SponsorSponsorshipsDTO, SponsorDto, SponsorDashboardMetricsDTO, SponsoredLeagueDTO, SponsorDashboardInvestmentDTO, SponsorshipDetailDTO } from './dto/SponsorDto'; -const sponsors: Map = new Map(); +// Use cases +import { GetSponsorshipPricingUseCase } from '@gridpilot/racing/application/use-cases/GetSponsorshipPricingUseCase'; +import { GetSponsorsUseCase } from '@gridpilot/racing/application/use-cases/GetSponsorsUseCase'; +import { CreateSponsorUseCase } from '@gridpilot/racing/application/use-cases/CreateSponsorUseCase'; +import { GetSponsorDashboardUseCase } from '@gridpilot/racing/application/use-cases/GetSponsorDashboardUseCase'; +import { GetSponsorSponsorshipsUseCase } from '@gridpilot/racing/application/use-cases/GetSponsorSponsorshipsUseCase'; + +// Presenters +import { GetSponsorshipPricingPresenter } from './presenters/GetSponsorshipPricingPresenter'; +import { GetSponsorsPresenter } from './presenters/GetSponsorsPresenter'; +import { CreateSponsorPresenter } from './presenters/CreateSponsorPresenter'; +import { GetSponsorDashboardPresenter } from './presenters/GetSponsorDashboardPresenter'; +import { GetSponsorSponsorshipsPresenter } from './presenters/GetSponsorSponsorshipsPresenter'; + +// Tokens +import { GET_SPONSORSHIP_PRICING_USE_CASE_TOKEN, GET_SPONSORS_USE_CASE_TOKEN, CREATE_SPONSOR_USE_CASE_TOKEN, GET_SPONSOR_DASHBOARD_USE_CASE_TOKEN, GET_SPONSOR_SPONSORSHIPS_USE_CASE_TOKEN, LOGGER_TOKEN } from './SponsorProviders'; +import { Logger } from '@gridpilot/shared/application'; @Injectable() export class SponsorService { + constructor( + @Inject(GET_SPONSORSHIP_PRICING_USE_CASE_TOKEN) private readonly getSponsorshipPricingUseCase: GetSponsorshipPricingUseCase, + @Inject(GET_SPONSORS_USE_CASE_TOKEN) private readonly getSponsorsUseCase: GetSponsorsUseCase, + @Inject(CREATE_SPONSOR_USE_CASE_TOKEN) private readonly createSponsorUseCase: CreateSponsorUseCase, + @Inject(GET_SPONSOR_DASHBOARD_USE_CASE_TOKEN) private readonly getSponsorDashboardUseCase: GetSponsorDashboardUseCase, + @Inject(GET_SPONSOR_SPONSORSHIPS_USE_CASE_TOKEN) private readonly getSponsorSponsorshipsUseCase: GetSponsorSponsorshipsUseCase, + @Inject(LOGGER_TOKEN) private readonly logger: Logger, + ) {} - constructor() { - // Seed some demo sponsors for dashboard if empty - if (sponsors.size === 0) { - const demoSponsor1: SponsorDto = { - id: 'sponsor-demo-1', - name: 'Demo Sponsor Co.', - contactEmail: 'contact@demosponsor.com', - websiteUrl: 'https://demosponsor.com', - logoUrl: 'https://fakeimg.pl/200x100/aaaaaa/ffffff?text=DemoCo', - createdAt: new Date(), - }; - const demoSponsor2: SponsorDto = { - id: 'sponsor-demo-2', - name: 'Second Brand', - contactEmail: 'info@secondbrand.net', - websiteUrl: 'https://secondbrand.net', - logoUrl: 'https://fakeimg.pl/200x100/cccccc/ffffff?text=Brand2', - createdAt: new Date(Date.now() - 86400000 * 5), - }; - sponsors.set(demoSponsor1.id, demoSponsor1); - sponsors.set(demoSponsor2.id, demoSponsor2); - } - } + async getEntitySponsorshipPricing(): Promise { + this.logger.debug('[SponsorService] Fetching sponsorship pricing.'); - getEntitySponsorshipPricing(): Promise { - // This logic relies on external factors (e.g., pricing configuration, entity type) - // For now, return mock data - return Promise.resolve({ - pricing: [ - { id: 'tier-bronze', level: 'Bronze', price: 100, currency: 'USD' }, - { id: 'tier-silver', level: 'Silver', price: 250, currency: 'USD' }, - { id: 'tier-gold', level: 'Gold', price: 500, currency: 'USD' }, - ], - }); + const presenter = new GetSponsorshipPricingPresenter(); + await this.getSponsorshipPricingUseCase.execute(undefined, presenter); + return presenter.viewModel; } async getSponsors(): Promise { - return { sponsors: Array.from(sponsors.values()) }; + this.logger.debug('[SponsorService] Fetching sponsors.'); + + const presenter = new GetSponsorsPresenter(); + await this.getSponsorsUseCase.execute(undefined, presenter); + return presenter.viewModel; } async createSponsor(input: CreateSponsorInput): Promise { - const id = `sponsor-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const newSponsor: SponsorDto = { - id, - name: input.name, - contactEmail: input.contactEmail, - websiteUrl: input.websiteUrl, - logoUrl: input.logoUrl, - createdAt: new Date(), - }; - sponsors.set(id, newSponsor); - return { sponsor: newSponsor }; + this.logger.debug('[SponsorService] Creating sponsor.', { input }); + + const presenter = new CreateSponsorPresenter(); + await this.createSponsorUseCase.execute(input, presenter); + return presenter.viewModel; } async getSponsorDashboard(params: GetSponsorDashboardQueryParams): Promise { - const { sponsorId } = params; + this.logger.debug('[SponsorService] Fetching sponsor dashboard.', { params }); - const sponsor = sponsors.get(sponsorId); - if (!sponsor) { - return null; - } - - // Simplified mock data for dashboard metrics and sponsored leagues - const metrics: SponsorDashboardMetricsDTO = { - impressions: 10000, - impressionsChange: 12.5, - uniqueViewers: 7000, - viewersChange: 8.3, - races: 50, - drivers: 100, - exposure: 75, - exposureChange: 5.2, - }; - - const sponsoredLeagues: SponsoredLeagueDTO[] = [ - { id: 'league-1', name: 'League 1', tier: 'main', drivers: 50, races: 10, impressions: 5000, status: 'active' }, - { id: 'league-2', name: 'League 2', tier: 'secondary', drivers: 30, races: 5, impressions: 1500, status: 'upcoming' }, - ]; - - const investment: SponsorDashboardInvestmentDTO = { - activeSponsorships: 2, - totalInvestment: 5000, - costPerThousandViews: 0.5, - }; - - return { - sponsorId, - sponsorName: sponsor.name, - metrics, - sponsoredLeagues, - investment, - }; + const presenter = new GetSponsorDashboardPresenter(); + await this.getSponsorDashboardUseCase.execute(params, presenter); + return presenter.viewModel as SponsorDashboardDTO | null; } async getSponsorSponsorships(params: GetSponsorSponsorshipsQueryParams): Promise { - const { sponsorId } = params; + this.logger.debug('[SponsorService] Fetching sponsor sponsorships.', { params }); - const sponsor = sponsors.get(sponsorId); - if (!sponsor) { - return null; - }; - - const sponsorshipDetails: SponsorshipDetailDTO[] = [ - { - id: 'sponsorship-1', - leagueId: 'league-1', - leagueName: 'League 1', - seasonId: 'season-1', - seasonName: 'Season 1', - seasonStartDate: new Date('2025-01-01'), - seasonEndDate: new Date('2025-12-31'), - tier: 'main', - status: 'active', - pricing: { amount: 1000, currency: 'USD' }, - platformFee: { amount: 100, currency: 'USD' }, - netAmount: { amount: 900, currency: 'USD' }, - metrics: { drivers: 50, races: 10, completedRaces: 8, impressions: 5000 }, - createdAt: new Date('2024-12-01'), - activatedAt: new Date('2025-01-01'), - }, - { - id: 'sponsorship-2', - leagueId: 'league-2', - leagueName: 'League 2', - seasonId: 'season-2', - seasonName: 'Season 2', - tier: 'secondary', - status: 'pending', - pricing: { amount: 500, currency: 'USD' }, - platformFee: { amount: 50, currency: 'USD' }, - netAmount: { amount: 450, currency: 'USD' }, - metrics: { drivers: 30, races: 5, completedRaces: 0, impressions: 0 }, - createdAt: new Date('2025-03-15'), - }, - ]; - - const totalInvestment = sponsorshipDetails.reduce((sum, s) => sum + s.pricing.amount, 0); - const totalPlatformFees = sponsorshipDetails.reduce((sum, s) => sum + s.platformFee.amount, 0); - const activeSponsorships = sponsorshipDetails.filter(s => s.status === 'active').length; - - return { - sponsorId, - sponsorName: sponsor.name, - sponsorships: sponsorshipDetails, - summary: { - totalSponsorships: sponsorshipDetails.length, - activeSponsorships, - totalInvestment, - totalPlatformFees, - currency: 'USD', - }, - }; + const presenter = new GetSponsorSponsorshipsPresenter(); + await this.getSponsorSponsorshipsUseCase.execute(params, presenter); + return presenter.viewModel as SponsorSponsorshipsDTO | null; } } diff --git a/apps/api/src/modules/sponsor/presenters/CreateSponsorPresenter.ts b/apps/api/src/modules/sponsor/presenters/CreateSponsorPresenter.ts new file mode 100644 index 000000000..544c7dd4c --- /dev/null +++ b/apps/api/src/modules/sponsor/presenters/CreateSponsorPresenter.ts @@ -0,0 +1,22 @@ +import { CreateSponsorViewModel, CreateSponsorResultDTO, ICreateSponsorPresenter } from '@gridpilot/racing/application/presenters/ICreateSponsorPresenter'; + +export class CreateSponsorPresenter implements ICreateSponsorPresenter { + private result: CreateSponsorViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: CreateSponsorResultDTO) { + this.result = dto; + } + + getViewModel(): CreateSponsorViewModel | null { + return this.result; + } + + get viewModel(): CreateSponsorViewModel { + if (!this.result) throw new Error('Presenter not presented'); + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/sponsor/presenters/GetEntitySponsorshipPricingPresenter.ts b/apps/api/src/modules/sponsor/presenters/GetEntitySponsorshipPricingPresenter.ts new file mode 100644 index 000000000..23ae599af --- /dev/null +++ b/apps/api/src/modules/sponsor/presenters/GetEntitySponsorshipPricingPresenter.ts @@ -0,0 +1,22 @@ +import type { GetEntitySponsorshipPricingResultDTO } from '@gridpilot/racing/application/use-cases/GetEntitySponsorshipPricingUseCase'; +import type { IEntitySponsorshipPricingPresenter } from '@gridpilot/racing/application/presenters/IEntitySponsorshipPricingPresenter'; + +export class GetEntitySponsorshipPricingPresenter implements IEntitySponsorshipPricingPresenter { + private result: GetEntitySponsorshipPricingResultDTO | null = null; + + reset() { + this.result = null; + } + + present(dto: GetEntitySponsorshipPricingResultDTO | null) { + this.result = dto; + } + + getViewModel(): GetEntitySponsorshipPricingResultDTO | null { + return this.result; + } + + get viewModel(): GetEntitySponsorshipPricingResultDTO | null { + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/sponsor/presenters/GetSponsorDashboardPresenter.ts b/apps/api/src/modules/sponsor/presenters/GetSponsorDashboardPresenter.ts new file mode 100644 index 000000000..fe849bb5d --- /dev/null +++ b/apps/api/src/modules/sponsor/presenters/GetSponsorDashboardPresenter.ts @@ -0,0 +1,22 @@ +import type { SponsorDashboardDTO } from '@gridpilot/racing/application/use-cases/GetSponsorDashboardUseCase'; +import type { ISponsorDashboardPresenter, SponsorDashboardViewModel } from '@gridpilot/racing/application/presenters/ISponsorDashboardPresenter'; + +export class GetSponsorDashboardPresenter implements ISponsorDashboardPresenter { + private result: SponsorDashboardViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: SponsorDashboardDTO | null) { + this.result = dto; + } + + getViewModel(): SponsorDashboardViewModel | null { + return this.result; + } + + get viewModel(): SponsorDashboardViewModel | null { + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/sponsor/presenters/GetSponsorSponsorshipsPresenter.ts b/apps/api/src/modules/sponsor/presenters/GetSponsorSponsorshipsPresenter.ts new file mode 100644 index 000000000..cb112af4e --- /dev/null +++ b/apps/api/src/modules/sponsor/presenters/GetSponsorSponsorshipsPresenter.ts @@ -0,0 +1,22 @@ +import type { SponsorSponsorshipsDTO } from '@gridpilot/racing/application/use-cases/GetSponsorSponsorshipsUseCase'; +import type { ISponsorSponsorshipsPresenter, SponsorSponsorshipsViewModel } from '@gridpilot/racing/application/presenters/ISponsorSponsorshipsPresenter'; + +export class GetSponsorSponsorshipsPresenter implements ISponsorSponsorshipsPresenter { + private result: SponsorSponsorshipsViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: SponsorSponsorshipsDTO | null) { + this.result = dto; + } + + getViewModel(): SponsorSponsorshipsViewModel | null { + return this.result; + } + + get viewModel(): SponsorSponsorshipsViewModel | null { + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/sponsor/presenters/GetSponsorsPresenter.ts b/apps/api/src/modules/sponsor/presenters/GetSponsorsPresenter.ts new file mode 100644 index 000000000..503ed6108 --- /dev/null +++ b/apps/api/src/modules/sponsor/presenters/GetSponsorsPresenter.ts @@ -0,0 +1,22 @@ +import { GetSponsorsViewModel, GetSponsorsResultDTO, IGetSponsorsPresenter } from '@gridpilot/racing/application/presenters/IGetSponsorsPresenter'; + +export class GetSponsorsPresenter implements IGetSponsorsPresenter { + private result: GetSponsorsViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: GetSponsorsResultDTO) { + this.result = dto; + } + + getViewModel(): GetSponsorsViewModel | null { + return this.result; + } + + get viewModel(): GetSponsorsViewModel { + if (!this.result) throw new Error('Presenter not presented'); + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/sponsor/presenters/GetSponsorshipPricingPresenter.ts b/apps/api/src/modules/sponsor/presenters/GetSponsorshipPricingPresenter.ts new file mode 100644 index 000000000..564e7609f --- /dev/null +++ b/apps/api/src/modules/sponsor/presenters/GetSponsorshipPricingPresenter.ts @@ -0,0 +1,22 @@ +import { GetSponsorshipPricingViewModel, GetSponsorshipPricingResultDTO, IGetSponsorshipPricingPresenter } from '@gridpilot/racing/application/presenters/IGetSponsorshipPricingPresenter'; + +export class GetSponsorshipPricingPresenter implements IGetSponsorshipPricingPresenter { + private result: GetSponsorshipPricingViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: GetSponsorshipPricingResultDTO) { + this.result = dto; + } + + getViewModel(): GetSponsorshipPricingViewModel | null { + return this.result; + } + + get viewModel(): GetSponsorshipPricingViewModel { + if (!this.result) throw new Error('Presenter not presented'); + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/team/TeamController.ts b/apps/api/src/modules/team/TeamController.ts index fdb260081..dd009473d 100644 --- a/apps/api/src/modules/team/TeamController.ts +++ b/apps/api/src/modules/team/TeamController.ts @@ -1,7 +1,7 @@ -import { Controller, Get, Param } from '@nestjs/common'; -import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger'; +import { Controller, Get, Post, Patch, Body, Param } from '@nestjs/common'; +import { ApiTags, ApiResponse, ApiOperation, ApiBody } from '@nestjs/swagger'; import { TeamService } from './TeamService'; -import { AllTeamsViewModel } from './dto/TeamDto'; +import { AllTeamsViewModel, DriverTeamViewModel, TeamDetailsViewModel, TeamMembersViewModel, TeamJoinRequestsViewModel, CreateTeamInput, CreateTeamOutput, UpdateTeamInput, UpdateTeamOutput, ApproveTeamJoinRequestInput, ApproveTeamJoinRequestOutput, RejectTeamJoinRequestInput, RejectTeamJoinRequestOutput } from './dto/TeamDto'; @ApiTags('teams') @Controller('teams') @@ -15,5 +15,79 @@ export class TeamController { return this.teamService.getAllTeams(); } - // Add other Team endpoints here based on other presenters + @Get(':teamId') + @ApiOperation({ summary: 'Get team details' }) + @ApiResponse({ status: 200, description: 'Team details', type: TeamDetailsViewModel }) + @ApiResponse({ status: 404, description: 'Team not found' }) + async getTeamDetails( + @Param('teamId') teamId: string, + ): Promise { + return this.teamService.getTeamDetails(teamId); + } + + @Get(':teamId/members') + @ApiOperation({ summary: 'Get team members' }) + @ApiResponse({ status: 200, description: 'Team members', type: TeamMembersViewModel }) + async getTeamMembers(@Param('teamId') teamId: string): Promise { + return this.teamService.getTeamMembers(teamId); + } + + @Get(':teamId/join-requests') + @ApiOperation({ summary: 'Get team join requests' }) + @ApiResponse({ status: 200, description: 'Team join requests', type: TeamJoinRequestsViewModel }) + async getTeamJoinRequests(@Param('teamId') teamId: string): Promise { + return this.teamService.getTeamJoinRequests(teamId); + } + + @Post(':teamId/join-requests/approve') + @ApiOperation({ summary: 'Approve a team join request' }) + @ApiBody({ type: ApproveTeamJoinRequestInput }) + @ApiResponse({ status: 200, description: 'Join request approved', type: ApproveTeamJoinRequestOutput }) + @ApiResponse({ status: 404, description: 'Join request not found' }) + async approveJoinRequest( + @Param('teamId') teamId: string, + @Body() input: ApproveTeamJoinRequestInput, + ): Promise { + return this.teamService.approveTeamJoinRequest({ ...input, teamId }); + } + + @Post(':teamId/join-requests/reject') + @ApiOperation({ summary: 'Reject a team join request' }) + @ApiBody({ type: RejectTeamJoinRequestInput }) + @ApiResponse({ status: 200, description: 'Join request rejected', type: RejectTeamJoinRequestOutput }) + @ApiResponse({ status: 404, description: 'Join request not found' }) + async rejectJoinRequest( + @Param('teamId') teamId: string, + @Body() input: RejectTeamJoinRequestInput, + ): Promise { + return this.teamService.rejectTeamJoinRequest({ ...input, teamId }); + } + + @Post() + @ApiOperation({ summary: 'Create a new team' }) + @ApiBody({ type: CreateTeamInput }) + @ApiResponse({ status: 201, description: 'Team created successfully', type: CreateTeamOutput }) + async createTeam(@Body() input: CreateTeamInput): Promise { + return this.teamService.createTeam(input); + } + + @Patch(':teamId') + @ApiOperation({ summary: 'Update team details' }) + @ApiBody({ type: UpdateTeamInput }) + @ApiResponse({ status: 200, description: 'Team updated successfully', type: UpdateTeamOutput }) + @ApiResponse({ status: 404, description: 'Team not found' }) + async updateTeam( + @Param('teamId') teamId: string, + @Body() input: UpdateTeamInput, + ): Promise { + return this.teamService.updateTeam({ ...input, teamId }); + } + + @Get('driver/:driverId') + @ApiOperation({ summary: 'Get team for a driver' }) + @ApiResponse({ status: 200, description: 'Driver team membership', type: DriverTeamViewModel }) + @ApiResponse({ status: 404, description: 'Driver not in a team' }) + async getDriverTeam(@Param('driverId') driverId: string): Promise { + return this.teamService.getDriverTeam({ teamId: '', driverId }); + } } diff --git a/apps/api/src/modules/team/TeamProviders.ts b/apps/api/src/modules/team/TeamProviders.ts index 2bd3aa5e0..688f541c0 100644 --- a/apps/api/src/modules/team/TeamProviders.ts +++ b/apps/api/src/modules/team/TeamProviders.ts @@ -4,24 +4,64 @@ import { TeamService } from './TeamService'; // Import core interfaces import { ITeamRepository } from '@gridpilot/racing/domain/repositories/ITeamRepository'; import { ITeamMembershipRepository } from '@gridpilot/racing/domain/repositories/ITeamMembershipRepository'; +import { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository'; +import { IImageServicePort } from '@gridpilot/racing/application/ports/IImageServicePort'; import { Logger } from '@gridpilot/shared/application/Logger'; // Import concrete in-memory implementations import { InMemoryTeamRepository } from 'adapters/racing/persistence/inmemory/InMemoryTeamRepository'; import { InMemoryTeamMembershipRepository } from 'adapters/racing/persistence/inmemory/InMemoryTeamMembershipRepository'; +import { InMemoryDriverRepository } from 'adapters/racing/persistence/inmemory/InMemoryDriverRepository'; import { ConsoleLogger } from 'adapters/logging/ConsoleLogger'; // Import use cases import { GetAllTeamsUseCase } from '@gridpilot/racing/application/use-cases/GetAllTeamsUseCase'; import { GetDriverTeamUseCase } from '@gridpilot/racing/application/use-cases/GetDriverTeamUseCase'; +import { GetTeamDetailsUseCase } from '@gridpilot/racing/application/use-cases/GetTeamDetailsUseCase'; +import { GetTeamMembersUseCase } from '@gridpilot/racing/application/use-cases/GetTeamMembersUseCase'; +import { GetTeamJoinRequestsUseCase } from '@gridpilot/racing/application/use-cases/GetTeamJoinRequestsUseCase'; +import { CreateTeamUseCase } from '@gridpilot/racing/application/use-cases/CreateTeamUseCase'; +import { UpdateTeamUseCase } from '@gridpilot/racing/application/use-cases/UpdateTeamUseCase'; +import { ApproveTeamJoinRequestUseCase } from '@gridpilot/racing/application/use-cases/ApproveTeamJoinRequestUseCase'; +import { RejectTeamJoinRequestUseCase } from '@gridpilot/racing/application/use-cases/RejectTeamJoinRequestUseCase'; + +// Import presenters for use case initialization +import { DriverTeamPresenter } from './presenters/DriverTeamPresenter'; +import { TeamMembersPresenter } from './presenters/TeamMembersPresenter'; +import { TeamJoinRequestsPresenter } from './presenters/TeamJoinRequestsPresenter'; // Tokens export const TEAM_REPOSITORY_TOKEN = 'ITeamRepository'; export const TEAM_MEMBERSHIP_REPOSITORY_TOKEN = 'ITeamMembershipRepository'; +export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository'; +export const IMAGE_SERVICE_TOKEN = 'IImageServicePort'; export const TEAM_GET_ALL_USE_CASE_TOKEN = 'GetAllTeamsUseCase'; export const TEAM_GET_DRIVER_TEAM_USE_CASE_TOKEN = 'GetDriverTeamUseCase'; +export const TEAM_GET_DETAILS_USE_CASE_TOKEN = 'GetTeamDetailsUseCase'; +export const TEAM_GET_MEMBERS_USE_CASE_TOKEN = 'GetTeamMembersUseCase'; +export const TEAM_GET_JOIN_REQUESTS_USE_CASE_TOKEN = 'GetTeamJoinRequestsUseCase'; +export const TEAM_CREATE_USE_CASE_TOKEN = 'CreateTeamUseCase'; +export const TEAM_UPDATE_USE_CASE_TOKEN = 'UpdateTeamUseCase'; +export const TEAM_APPROVE_JOIN_REQUEST_USE_CASE_TOKEN = 'ApproveTeamJoinRequestUseCase'; +export const TEAM_REJECT_JOIN_REQUEST_USE_CASE_TOKEN = 'RejectTeamJoinRequestUseCase'; export const TEAM_LOGGER_TOKEN = 'Logger'; +// Simple image service implementation for team module +class SimpleImageService implements IImageServicePort { + getDriverAvatar(driverId: string): string { + return `/api/media/avatars/${driverId}`; + } + getTeamLogo(teamId: string): string { + return `/api/media/teams/${teamId}/logo`; + } + getLeagueCover(leagueId: string): string { + return `/api/media/leagues/${leagueId}/cover`; + } + getLeagueLogo(leagueId: string): string { + return `/api/media/leagues/${leagueId}/logo`; + } +} + export const TeamProviders: Provider[] = [ TeamService, // Provide the service itself { @@ -34,6 +74,15 @@ export const TeamProviders: Provider[] = [ useFactory: (logger: Logger) => new InMemoryTeamMembershipRepository(logger), inject: [TEAM_LOGGER_TOKEN], }, + { + provide: DRIVER_REPOSITORY_TOKEN, + useFactory: (logger: Logger) => new InMemoryDriverRepository(logger), + inject: [TEAM_LOGGER_TOKEN], + }, + { + provide: IMAGE_SERVICE_TOKEN, + useClass: SimpleImageService, + }, { provide: TEAM_LOGGER_TOKEN, useClass: ConsoleLogger, @@ -48,7 +97,57 @@ export const TeamProviders: Provider[] = [ { provide: TEAM_GET_DRIVER_TEAM_USE_CASE_TOKEN, useFactory: (teamRepo: ITeamRepository, membershipRepo: ITeamMembershipRepository, logger: Logger) => - new GetDriverTeamUseCase(teamRepo, membershipRepo, logger), + new GetDriverTeamUseCase(teamRepo, membershipRepo, logger, new DriverTeamPresenter()), inject: [TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, TEAM_LOGGER_TOKEN], }, + { + provide: TEAM_GET_DETAILS_USE_CASE_TOKEN, + useFactory: (teamRepo: ITeamRepository, membershipRepo: ITeamMembershipRepository) => + new GetTeamDetailsUseCase(teamRepo, membershipRepo), + inject: [TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN], + }, + { + provide: TEAM_GET_MEMBERS_USE_CASE_TOKEN, + useFactory: ( + membershipRepo: ITeamMembershipRepository, + driverRepo: IDriverRepository, + imageService: IImageServicePort, + logger: Logger, + ) => new GetTeamMembersUseCase(membershipRepo, driverRepo, imageService, logger, new TeamMembersPresenter()), + inject: [TEAM_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, IMAGE_SERVICE_TOKEN, TEAM_LOGGER_TOKEN], + }, + { + provide: TEAM_GET_JOIN_REQUESTS_USE_CASE_TOKEN, + useFactory: ( + membershipRepo: ITeamMembershipRepository, + driverRepo: IDriverRepository, + imageService: IImageServicePort, + logger: Logger, + ) => new GetTeamJoinRequestsUseCase(membershipRepo, driverRepo, imageService, logger, new TeamJoinRequestsPresenter()), + inject: [TEAM_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, IMAGE_SERVICE_TOKEN, TEAM_LOGGER_TOKEN], + }, + { + provide: TEAM_CREATE_USE_CASE_TOKEN, + useFactory: (teamRepo: ITeamRepository, membershipRepo: ITeamMembershipRepository) => + new CreateTeamUseCase(teamRepo, membershipRepo), + inject: [TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN], + }, + { + provide: TEAM_UPDATE_USE_CASE_TOKEN, + useFactory: (teamRepo: ITeamRepository, membershipRepo: ITeamMembershipRepository) => + new UpdateTeamUseCase(teamRepo, membershipRepo), + inject: [TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN], + }, + { + provide: TEAM_APPROVE_JOIN_REQUEST_USE_CASE_TOKEN, + useFactory: (membershipRepo: ITeamMembershipRepository, logger: Logger) => + new ApproveTeamJoinRequestUseCase(membershipRepo, logger), + inject: [TEAM_MEMBERSHIP_REPOSITORY_TOKEN, TEAM_LOGGER_TOKEN], + }, + { + provide: TEAM_REJECT_JOIN_REQUEST_USE_CASE_TOKEN, + useFactory: (membershipRepo: ITeamMembershipRepository) => + new RejectTeamJoinRequestUseCase(membershipRepo), + inject: [TEAM_MEMBERSHIP_REPOSITORY_TOKEN], + }, ]; diff --git a/apps/api/src/modules/team/TeamService.ts b/apps/api/src/modules/team/TeamService.ts index e707f505b..2c0f64717 100644 --- a/apps/api/src/modules/team/TeamService.ts +++ b/apps/api/src/modules/team/TeamService.ts @@ -1,25 +1,53 @@ import { Injectable, Inject } from '@nestjs/common'; -import { AllTeamsViewModel, GetDriverTeamQuery, DriverTeamViewModel } from './dto/TeamDto'; +import { AllTeamsViewModel, GetDriverTeamQuery, DriverTeamViewModel, TeamDetailsViewModel, TeamMembersViewModel, TeamJoinRequestsViewModel, CreateTeamInput, CreateTeamOutput, UpdateTeamInput, UpdateTeamOutput, ApproveTeamJoinRequestInput, ApproveTeamJoinRequestOutput, RejectTeamJoinRequestInput, RejectTeamJoinRequestOutput } from './dto/TeamDto'; // Use cases import { GetAllTeamsUseCase } from '@gridpilot/racing/application/use-cases/GetAllTeamsUseCase'; import { GetDriverTeamUseCase } from '@gridpilot/racing/application/use-cases/GetDriverTeamUseCase'; +import { GetTeamDetailsUseCase } from '@gridpilot/racing/application/use-cases/GetTeamDetailsUseCase'; +import { GetTeamMembersUseCase } from '@gridpilot/racing/application/use-cases/GetTeamMembersUseCase'; +import { GetTeamJoinRequestsUseCase } from '@gridpilot/racing/application/use-cases/GetTeamJoinRequestsUseCase'; +import { CreateTeamUseCase } from '@gridpilot/racing/application/use-cases/CreateTeamUseCase'; +import { UpdateTeamUseCase } from '@gridpilot/racing/application/use-cases/UpdateTeamUseCase'; +import { ApproveTeamJoinRequestUseCase } from '@gridpilot/racing/application/use-cases/ApproveTeamJoinRequestUseCase'; +import { RejectTeamJoinRequestUseCase } from '@gridpilot/racing/application/use-cases/RejectTeamJoinRequestUseCase'; // Presenters import { AllTeamsPresenter } from './presenters/AllTeamsPresenter'; import { DriverTeamPresenter } from './presenters/DriverTeamPresenter'; +import { TeamDetailsPresenter } from './presenters/TeamDetailsPresenter'; +import { TeamMembersPresenter } from './presenters/TeamMembersPresenter'; +import { TeamJoinRequestsPresenter } from './presenters/TeamJoinRequestsPresenter'; // Logger import { Logger } from '@gridpilot/shared/application/Logger'; // Tokens -import { TEAM_GET_ALL_USE_CASE_TOKEN, TEAM_GET_DRIVER_TEAM_USE_CASE_TOKEN, TEAM_LOGGER_TOKEN } from './TeamProviders'; +import { + TEAM_GET_ALL_USE_CASE_TOKEN, + TEAM_GET_DRIVER_TEAM_USE_CASE_TOKEN, + TEAM_GET_DETAILS_USE_CASE_TOKEN, + TEAM_GET_MEMBERS_USE_CASE_TOKEN, + TEAM_GET_JOIN_REQUESTS_USE_CASE_TOKEN, + TEAM_CREATE_USE_CASE_TOKEN, + TEAM_UPDATE_USE_CASE_TOKEN, + TEAM_APPROVE_JOIN_REQUEST_USE_CASE_TOKEN, + TEAM_REJECT_JOIN_REQUEST_USE_CASE_TOKEN, + TEAM_LOGGER_TOKEN +} from './TeamProviders'; @Injectable() export class TeamService { constructor( @Inject(TEAM_GET_ALL_USE_CASE_TOKEN) private readonly getAllTeamsUseCase: GetAllTeamsUseCase, @Inject(TEAM_GET_DRIVER_TEAM_USE_CASE_TOKEN) private readonly getDriverTeamUseCase: GetDriverTeamUseCase, + @Inject(TEAM_GET_DETAILS_USE_CASE_TOKEN) private readonly getTeamDetailsUseCase: GetTeamDetailsUseCase, + @Inject(TEAM_GET_MEMBERS_USE_CASE_TOKEN) private readonly getTeamMembersUseCase: GetTeamMembersUseCase, + @Inject(TEAM_GET_JOIN_REQUESTS_USE_CASE_TOKEN) private readonly getTeamJoinRequestsUseCase: GetTeamJoinRequestsUseCase, + @Inject(TEAM_CREATE_USE_CASE_TOKEN) private readonly createTeamUseCase: CreateTeamUseCase, + @Inject(TEAM_UPDATE_USE_CASE_TOKEN) private readonly updateTeamUseCase: UpdateTeamUseCase, + @Inject(TEAM_APPROVE_JOIN_REQUEST_USE_CASE_TOKEN) private readonly approveTeamJoinRequestUseCase: ApproveTeamJoinRequestUseCase, + @Inject(TEAM_REJECT_JOIN_REQUEST_USE_CASE_TOKEN) private readonly rejectTeamJoinRequestUseCase: RejectTeamJoinRequestUseCase, @Inject(TEAM_LOGGER_TOKEN) private readonly logger: Logger, ) {} @@ -28,7 +56,7 @@ export class TeamService { const presenter = new AllTeamsPresenter(); await this.getAllTeamsUseCase.execute(undefined, presenter); - return presenter.viewModel; + return presenter.viewModel as unknown as AllTeamsViewModel; } async getDriverTeam(query: GetDriverTeamQuery): Promise { @@ -37,10 +65,104 @@ export class TeamService { const presenter = new DriverTeamPresenter(); try { await this.getDriverTeamUseCase.execute({ driverId: query.driverId }, presenter); - return presenter.viewModel; + return presenter.viewModel as unknown as DriverTeamViewModel; } catch (error) { this.logger.error(`Error fetching driver team: ${error}`); return null; } } + + async getTeamDetails(teamId: string): Promise { + this.logger.debug(`[TeamService] Fetching team details for teamId: ${teamId}`); + + const presenter = new TeamDetailsPresenter(); + try { + await this.getTeamDetailsUseCase.execute({ teamId, driverId: '' }, presenter); + return presenter.viewModel as unknown as TeamDetailsViewModel; + } catch (error) { + this.logger.error(`Error fetching team details: ${error}`); + return null; + } + } + + async getTeamMembers(teamId: string): Promise { + this.logger.debug(`[TeamService] Fetching team members for teamId: ${teamId}`); + + const presenter = new TeamMembersPresenter(); + await this.getTeamMembersUseCase.execute({ teamId }, presenter); + return presenter.viewModel as unknown as TeamMembersViewModel; + } + + async getTeamJoinRequests(teamId: string): Promise { + this.logger.debug(`[TeamService] Fetching join requests for teamId: ${teamId}`); + + const presenter = new TeamJoinRequestsPresenter(); + await this.getTeamJoinRequestsUseCase.execute({ teamId }, presenter); + return presenter.viewModel as unknown as TeamJoinRequestsViewModel; + } + + async createTeam(input: CreateTeamInput): Promise { + this.logger.debug('[TeamService] Creating team', input); + + try { + const result = await this.createTeamUseCase.execute({ + name: input.name, + tag: input.tag, + description: input.description, + ownerId: input.ownerId, + leagues: [], + }); + return { + teamId: result.team.id, + success: true, + }; + } catch (error) { + this.logger.error(`Error creating team: ${error}`); + throw error; + } + } + + async updateTeam(input: UpdateTeamInput & { teamId: string }): Promise { + this.logger.debug('[TeamService] Updating team', input); + + try { + await this.updateTeamUseCase.execute({ + teamId: input.teamId, + updates: { + name: input.name, + tag: input.tag, + description: input.description, + }, + updatedBy: input.updatedBy, + }); + return { success: true }; + } catch (error) { + this.logger.error(`Error updating team: ${error}`); + throw error; + } + } + + async approveTeamJoinRequest(input: ApproveTeamJoinRequestInput & { teamId: string }): Promise { + this.logger.debug('[TeamService] Approving team join request', input); + + try { + await this.approveTeamJoinRequestUseCase.execute({ requestId: input.requestId }); + return { success: true }; + } catch (error) { + this.logger.error(`Error approving join request: ${error}`); + throw error; + } + } + + async rejectTeamJoinRequest(input: RejectTeamJoinRequestInput & { teamId: string }): Promise { + this.logger.debug('[TeamService] Rejecting team join request', input); + + try { + await this.rejectTeamJoinRequestUseCase.execute({ requestId: input.requestId }); + return { success: true }; + } catch (error) { + this.logger.error(`Error rejecting join request: ${error}`); + throw error; + } + } } diff --git a/apps/api/src/modules/team/dto/TeamDto.ts b/apps/api/src/modules/team/dto/TeamDto.ts index c3c70c1ec..a4b192d05 100644 --- a/apps/api/src/modules/team/dto/TeamDto.ts +++ b/apps/api/src/modules/team/dto/TeamDto.ts @@ -1,16 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsString, IsNotEmpty, IsEnum, IsBoolean, IsDate, IsOptional } from 'class-validator'; - -export class TeamLeagueDto { - @ApiProperty() - id: string; - - @ApiProperty() - name: string; - - @ApiProperty({ nullable: true }) - logoUrl?: string; -} +import { IsString, IsNotEmpty, IsBoolean, IsOptional } from 'class-validator'; export class TeamListItemViewModel { @ApiProperty() @@ -19,17 +8,26 @@ export class TeamListItemViewModel { @ApiProperty() name: string; - @ApiProperty({ nullable: true }) - tag?: string; + @ApiProperty() + tag: string; - @ApiProperty({ nullable: true }) - description?: string; + @ApiProperty() + description: string; @ApiProperty() memberCount: number; - @ApiProperty({ type: [TeamLeagueDto] }) - leagues: TeamLeagueDto[]; + @ApiProperty({ type: [String] }) + leagues: string[]; + + @ApiProperty({ required: false }) + specialization?: 'endurance' | 'sprint' | 'mixed'; + + @ApiProperty({ required: false }) + region?: string; + + @ApiProperty({ type: [String], required: false }) + languages?: string[]; } export class AllTeamsViewModel { @@ -40,11 +38,169 @@ export class AllTeamsViewModel { totalCount: number; } -export class TeamDto { +export class TeamViewModel { @ApiProperty() - @IsString() id: string; + @ApiProperty() + name: string; + + @ApiProperty() + tag: string; + + @ApiProperty() + description: string; + + @ApiProperty() + ownerId: string; + + @ApiProperty({ type: [String] }) + leagues: string[]; + + @ApiProperty({ required: false }) + createdAt?: string; + + @ApiProperty({ required: false }) + specialization?: 'endurance' | 'sprint' | 'mixed'; + + @ApiProperty({ required: false }) + region?: string; + + @ApiProperty({ type: [String], required: false }) + languages?: string[]; +} + +export enum MembershipRole { + OWNER = 'owner', + MANAGER = 'manager', + MEMBER = 'member', +} + +export enum MembershipStatus { + ACTIVE = 'active', + PENDING = 'pending', + INVITED = 'invited', + INACTIVE = 'inactive', +} + +export class MembershipViewModel { + @ApiProperty() + role: 'owner' | 'manager' | 'member'; + + @ApiProperty() + joinedAt: string; + + @ApiProperty() + isActive: boolean; +} + +export class DriverTeamViewModel { + @ApiProperty({ type: TeamViewModel }) + team: TeamViewModel; + + @ApiProperty({ type: MembershipViewModel }) + membership: MembershipViewModel; + + @ApiProperty() + isOwner: boolean; + + @ApiProperty() + canManage: boolean; +} + +export class GetDriverTeamQuery { + @ApiProperty() + @IsString() + teamId: string; + + @ApiProperty() + @IsString() + driverId: string; +} + +export class TeamDetailsViewModel { + @ApiProperty({ type: TeamViewModel }) + team: TeamViewModel; + + @ApiProperty({ type: MembershipViewModel, nullable: true }) + membership: MembershipViewModel | null; + + @ApiProperty() + canManage: boolean; +} + +export class TeamMemberViewModel { + @ApiProperty() + driverId: string; + + @ApiProperty() + driverName: string; + + @ApiProperty() + role: 'owner' | 'manager' | 'member'; + + @ApiProperty() + joinedAt: string; + + @ApiProperty() + isActive: boolean; + + @ApiProperty() + avatarUrl: string; +} + +export class TeamMembersViewModel { + @ApiProperty({ type: [TeamMemberViewModel] }) + members: TeamMemberViewModel[]; + + @ApiProperty() + totalCount: number; + + @ApiProperty() + ownerCount: number; + + @ApiProperty() + managerCount: number; + + @ApiProperty() + memberCount: number; +} + +export class TeamJoinRequestViewModel { + @ApiProperty() + requestId: string; + + @ApiProperty() + driverId: string; + + @ApiProperty() + driverName: string; + + @ApiProperty() + teamId: string; + + @ApiProperty() + status: 'pending' | 'approved' | 'rejected'; + + @ApiProperty() + requestedAt: string; + + @ApiProperty() + avatarUrl: string; +} + +export class TeamJoinRequestsViewModel { + @ApiProperty({ type: [TeamJoinRequestViewModel] }) + requests: TeamJoinRequestViewModel[]; + + @ApiProperty() + pendingCount: number; + + @ApiProperty() + totalCount: number; +} + +export class CreateTeamInput { @ApiProperty() @IsString() @IsNotEmpty() @@ -63,60 +219,80 @@ export class TeamDto { @ApiProperty() @IsString() ownerId: string; - - @ApiProperty({ type: [TeamLeagueDto] }) - leagues: TeamLeagueDto[]; } -export enum MembershipRole { - OWNER = 'owner', - MANAGER = 'manager', - MEMBER = 'member', -} - -export enum MembershipStatus { - ACTIVE = 'active', - PENDING = 'pending', - INVITED = 'invited', - INACTIVE = 'inactive', -} - -export class MembershipDto { - @ApiProperty({ enum: MembershipRole }) - @IsEnum(MembershipRole) - role: MembershipRole; - - @ApiProperty() - @IsDate() - joinedAt: Date; - - @ApiProperty() - @IsBoolean() - isActive: boolean; -} - -export class DriverTeamViewModel { - @ApiProperty({ type: TeamDto }) - team: TeamDto; - - @ApiProperty({ type: MembershipDto }) - membership: MembershipDto; - - @ApiProperty() - @IsBoolean() - isOwner: boolean; - - @ApiProperty() - @IsBoolean() - canManage: boolean; -} - -export class GetDriverTeamQuery { +export class CreateTeamOutput { @ApiProperty() @IsString() teamId: string; @ApiProperty() - @IsString() - driverId: string; + @IsBoolean() + success: boolean; +} + +export class UpdateTeamInput { + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + teamId?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + name?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + tag?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + description?: string; + + @ApiProperty() + @IsString() + updatedBy: string; +} + +export class UpdateTeamOutput { + @ApiProperty() + @IsBoolean() + success: boolean; +} + +export class ApproveTeamJoinRequestInput { + @ApiProperty() + @IsString() + requestId: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + teamId?: string; +} + +export class ApproveTeamJoinRequestOutput { + @ApiProperty() + @IsBoolean() + success: boolean; +} + +export class RejectTeamJoinRequestInput { + @ApiProperty() + @IsString() + requestId: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + teamId?: string; +} + +export class RejectTeamJoinRequestOutput { + @ApiProperty() + @IsBoolean() + success: boolean; } diff --git a/apps/api/src/modules/team/presenters/AllTeamsPresenter.ts b/apps/api/src/modules/team/presenters/AllTeamsPresenter.ts index 3f59d7acf..59f7ba13e 100644 --- a/apps/api/src/modules/team/presenters/AllTeamsPresenter.ts +++ b/apps/api/src/modules/team/presenters/AllTeamsPresenter.ts @@ -1,5 +1,4 @@ -import { IAllTeamsPresenter, AllTeamsResultDTO, AllTeamsViewModel } from '@gridpilot/racing/application/presenters/IAllTeamsPresenter'; -import { TeamListItemViewModel } from '../dto/TeamDto'; +import { IAllTeamsPresenter, AllTeamsResultDTO, AllTeamsViewModel, TeamListItemViewModel } from '@gridpilot/racing/application/presenters/IAllTeamsPresenter'; export class AllTeamsPresenter implements IAllTeamsPresenter { private result: AllTeamsViewModel | null = null; @@ -24,6 +23,10 @@ export class AllTeamsPresenter implements IAllTeamsPresenter { }; } + getViewModel(): AllTeamsViewModel | null { + return this.result; + } + get viewModel(): AllTeamsViewModel { if (!this.result) throw new Error('Presenter not presented'); return this.result; diff --git a/apps/api/src/modules/team/presenters/DriverTeamPresenter.ts b/apps/api/src/modules/team/presenters/DriverTeamPresenter.ts index 06d7af71b..55703cb60 100644 --- a/apps/api/src/modules/team/presenters/DriverTeamPresenter.ts +++ b/apps/api/src/modules/team/presenters/DriverTeamPresenter.ts @@ -1,5 +1,4 @@ import { IDriverTeamPresenter, DriverTeamResultDTO, DriverTeamViewModel } from '@gridpilot/racing/application/presenters/IDriverTeamPresenter'; -import { TeamDto, MembershipDto, MembershipRole } from '../dto/TeamDto'; export class DriverTeamPresenter implements IDriverTeamPresenter { private result: DriverTeamViewModel | null = null; @@ -9,32 +8,32 @@ export class DriverTeamPresenter implements IDriverTeamPresenter { } present(dto: DriverTeamResultDTO) { - const team: TeamDto = { - id: dto.team.id, - name: dto.team.name, - tag: dto.team.tag, - description: dto.team.description, - ownerId: dto.team.ownerId, - leagues: dto.team.leagues || [], - }; - - const membership: MembershipDto = { - role: dto.membership.role as MembershipRole, - joinedAt: dto.membership.joinedAt, - isActive: dto.membership.status === 'active', - }; - const isOwner = dto.team.ownerId === dto.driverId; - const canManage = isOwner || membership.role === MembershipRole.MANAGER; + const canManage = isOwner || dto.membership.role === 'owner' || dto.membership.role === 'manager'; this.result = { - team, - membership, + team: { + id: dto.team.id, + name: dto.team.name, + tag: dto.team.tag, + description: dto.team.description || '', + ownerId: dto.team.ownerId, + leagues: dto.team.leagues || [], + }, + membership: { + role: dto.membership.role as 'owner' | 'manager' | 'member', + joinedAt: dto.membership.joinedAt.toISOString(), + isActive: dto.membership.status === 'active', + }, isOwner, canManage, }; } + getViewModel(): DriverTeamViewModel | null { + return this.result; + } + get viewModel(): DriverTeamViewModel { if (!this.result) throw new Error('Presenter not presented'); return this.result; diff --git a/apps/api/src/modules/team/presenters/TeamDetailsPresenter.ts b/apps/api/src/modules/team/presenters/TeamDetailsPresenter.ts new file mode 100644 index 000000000..b53e3521c --- /dev/null +++ b/apps/api/src/modules/team/presenters/TeamDetailsPresenter.ts @@ -0,0 +1,50 @@ +import { + ITeamDetailsPresenter, + TeamDetailsResultDTO, + TeamDetailsViewModel, +} from '@gridpilot/racing/application/presenters/ITeamDetailsPresenter'; + +export class TeamDetailsPresenter implements ITeamDetailsPresenter { + private result: TeamDetailsViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: TeamDetailsResultDTO) { + const { team, membership } = dto; + + const canManage = + membership !== null && + (membership.role === 'owner' || membership.role === 'manager'); + + this.result = { + team: { + id: team.id, + name: team.name, + tag: team.tag, + description: team.description, + ownerId: team.ownerId, + leagues: team.leagues || [], + createdAt: team.createdAt?.toISOString() || new Date().toISOString(), + }, + membership: membership + ? { + role: membership.role as 'owner' | 'manager' | 'member', + joinedAt: membership.joinedAt.toISOString(), + isActive: membership.status === 'active', + } + : null, + canManage, + }; + } + + getViewModel(): TeamDetailsViewModel | null { + return this.result; + } + + get viewModel(): TeamDetailsViewModel { + if (!this.result) throw new Error('Presenter not presented'); + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/team/presenters/TeamJoinRequestsPresenter.ts b/apps/api/src/modules/team/presenters/TeamJoinRequestsPresenter.ts new file mode 100644 index 000000000..29c8cb509 --- /dev/null +++ b/apps/api/src/modules/team/presenters/TeamJoinRequestsPresenter.ts @@ -0,0 +1,43 @@ +import { + ITeamJoinRequestsPresenter, + TeamJoinRequestsResultDTO, + TeamJoinRequestsViewModel, + TeamJoinRequestViewModel, +} from '@gridpilot/racing/application/presenters/ITeamJoinRequestsPresenter'; + +export class TeamJoinRequestsPresenter implements ITeamJoinRequestsPresenter { + private result: TeamJoinRequestsViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: TeamJoinRequestsResultDTO) { + const { requests, driverNames, avatarUrls } = dto; + + const requestViewModels: TeamJoinRequestViewModel[] = requests.map((request) => ({ + requestId: request.id, + driverId: request.driverId, + driverName: driverNames[request.driverId] || 'Unknown', + teamId: request.teamId, + status: 'pending' as const, + requestedAt: request.requestedAt.toISOString(), + avatarUrl: avatarUrls[request.driverId] || '', + })); + + this.result = { + requests: requestViewModels, + pendingCount: requestViewModels.length, + totalCount: requestViewModels.length, + }; + } + + getViewModel(): TeamJoinRequestsViewModel | null { + return this.result; + } + + get viewModel(): TeamJoinRequestsViewModel { + if (!this.result) throw new Error('Presenter not presented'); + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/team/presenters/TeamMembersPresenter.ts b/apps/api/src/modules/team/presenters/TeamMembersPresenter.ts new file mode 100644 index 000000000..461ec41cb --- /dev/null +++ b/apps/api/src/modules/team/presenters/TeamMembersPresenter.ts @@ -0,0 +1,48 @@ +import { + ITeamMembersPresenter, + TeamMembersResultDTO, + TeamMembersViewModel, + TeamMemberViewModel, +} from '@gridpilot/racing/application/presenters/ITeamMembersPresenter'; + +export class TeamMembersPresenter implements ITeamMembersPresenter { + private result: TeamMembersViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: TeamMembersResultDTO) { + const { memberships, driverNames, avatarUrls } = dto; + + const members: TeamMemberViewModel[] = memberships.map((membership) => ({ + driverId: membership.driverId, + driverName: driverNames[membership.driverId] || 'Unknown', + role: membership.role as 'owner' | 'manager' | 'member', + joinedAt: membership.joinedAt.toISOString(), + isActive: membership.status === 'active', + avatarUrl: avatarUrls[membership.driverId] || '', + })); + + const ownerCount = members.filter((m) => m.role === 'owner').length; + const managerCount = members.filter((m) => m.role === 'manager').length; + const memberCount = members.filter((m) => m.role === 'member').length; + + this.result = { + members, + totalCount: members.length, + ownerCount, + managerCount, + memberCount, + }; + } + + getViewModel(): TeamMembersViewModel | null { + return this.result; + } + + get viewModel(): TeamMembersViewModel { + if (!this.result) throw new Error('Presenter not presented'); + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 91a6fa485..59e8e892c 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -34,6 +34,15 @@ "@gridpilot/shared/application/*": [ "../../core/shared/application/*" ], + "@gridpilot/payments/*": [ + "../../core/payments/*" + ], + "@gridpilot/payments/application/*": [ + "../../core/payments/application/*" + ], + "@gridpilot/payments/domain/*": [ + "../../core/payments/domain/*" + ], "@gridpilot/racing/*": [ "../../core/racing/*" ], diff --git a/apps/website/app/auth/iracing/start/route.ts b/apps/website/app/auth/iracing/start/route.ts index 3302eaf57..44fa00a96 100644 --- a/apps/website/app/auth/iracing/start/route.ts +++ b/apps/website/app/auth/iracing/start/route.ts @@ -1,14 +1,15 @@ import { cookies } from 'next/headers'; import { NextResponse } from 'next/server'; -import { getAuthService } from '../../../../lib/auth'; +import { apiClient } from '../../../../lib/apiClient'; export async function GET(request: Request) { const url = new URL(request.url); const returnTo = url.searchParams.get('returnTo') ?? undefined; - const authService = getAuthService(); - const { redirectUrl, state } = await authService.startIracingAuthRedirect(returnTo); + const redirectUrl = apiClient.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); const cookieStore = await cookies(); cookieStore.set('gp_demo_auth_state', state, { diff --git a/apps/website/lib/apiClient.ts b/apps/website/lib/apiClient.ts index ac9a232e2..9dafc9194 100644 --- a/apps/website/lib/apiClient.ts +++ b/apps/website/lib/apiClient.ts @@ -1,11 +1,493 @@ -export class ApiClient { +/** + * Domain-specific API Client for GridPilot Website + * + * This module provides a strongly-typed HTTP client for all API operations. + * The website should use these methods instead of directly importing core use cases. + */ + +// ============================================================================ +// Types - These mirror the API DTOs +// ============================================================================ + +// Common Types +export interface DriverDTO { + id: string; + name: string; + avatarUrl?: string; + iracingId?: string; + rating?: number; +} + +export interface ProtestViewModel { + id: string; + raceId: string; + complainantId: string; + defendantId: string; + description: string; + status: string; + createdAt: string; +} + +export interface LeagueMemberViewModel { + driverId: string; + driver?: DriverDTO; + role: string; + joinedAt: string; +} + +export interface StandingEntryViewModel { + driverId: string; + driver?: DriverDTO; + position: number; + points: number; + wins: number; + podiums: number; + races: number; +} + +export interface ScheduledRaceViewModel { + id: string; + name: string; + scheduledTime: string; + status: string; + trackName?: string; +} + +// League Types +export interface LeagueSummaryViewModel { + id: string; + name: string; + description?: string; + logoUrl?: string; + coverImage?: string; + memberCount: number; + maxMembers: number; + isPublic: boolean; + ownerId: string; + ownerName?: string; + scoringType?: string; + status?: string; +} + +export interface AllLeaguesWithCapacityViewModel { + leagues: LeagueSummaryViewModel[]; +} + +export interface LeagueStatsDto { + totalLeagues: number; +} + +export interface LeagueJoinRequestViewModel { + id: string; + leagueId: string; + driverId: string; + requestedAt: Date; + message?: string; +} + +export interface LeagueAdminPermissionsViewModel { + canManageMembers: boolean; + canManageRaces: boolean; + canManageSettings: boolean; + canManageProtests: boolean; + isOwner: boolean; + isAdmin: boolean; +} + +export interface LeagueOwnerSummaryViewModel { + leagueId: string; + leagueName: string; + memberCount: number; + pendingRequests: number; +} + +export interface LeagueConfigFormModelDto { + id: string; + name: string; + description?: string; + isPublic: boolean; + maxMembers: number; + // Add other config fields as needed +} + +export interface LeagueAdminProtestsViewModel { + protests: ProtestViewModel[]; +} + +export interface LeagueSeasonSummaryViewModel { + id: string; + name: string; + startDate?: string; + endDate?: string; + status: string; +} + +export interface LeagueMembershipsViewModel { + members: LeagueMemberViewModel[]; +} + +export interface LeagueStandingsViewModel { + standings: StandingEntryViewModel[]; +} + +export interface LeagueScheduleViewModel { + races: ScheduledRaceViewModel[]; +} + +export interface LeagueStatsViewModel { + leagueId: string; + totalRaces: number; + completedRaces: number; + scheduledRaces: number; + averageSOF?: number; + highestSOF?: number; + lowestSOF?: number; +} + +export interface LeagueAdminViewModel { + config: LeagueConfigFormModelDto; + members: LeagueMemberViewModel[]; + joinRequests: LeagueJoinRequestViewModel[]; +} + +export interface CreateLeagueInput { + name: string; + description?: string; + isPublic: boolean; + maxMembers: number; + ownerId: string; +} + +export interface CreateLeagueOutput { + leagueId: string; + success: boolean; +} + +// Driver Types +export interface DriverLeaderboardItemViewModel { + id: string; + name: string; + avatarUrl?: string; + rating: number; + wins: number; + races: number; + skillLevel: string; +} + +export interface DriversLeaderboardViewModel { + drivers: DriverLeaderboardItemViewModel[]; +} + +export interface DriverStatsDto { + totalDrivers: number; +} + +export interface CompleteOnboardingInput { + iracingId: string; + displayName: string; +} + +export interface CompleteOnboardingOutput { + driverId: string; + success: boolean; +} + +export interface DriverRegistrationStatusViewModel { + isRegistered: boolean; + raceId: string; + driverId: string; +} + +// Team Types +export interface TeamSummaryViewModel { + id: string; + name: string; + logoUrl?: string; + memberCount: number; + rating: number; +} + +export interface AllTeamsViewModel { + teams: TeamSummaryViewModel[]; +} + +export interface TeamMemberViewModel { + driverId: string; + driver?: DriverDTO; + role: string; + joinedAt: string; +} + +export interface TeamJoinRequestItemViewModel { + id: string; + teamId: string; + driverId: string; + requestedAt: string; + message?: string; +} + +export interface TeamDetailsViewModel { + id: string; + name: string; + description?: string; + logoUrl?: string; + memberCount: number; + ownerId: string; + members: TeamMemberViewModel[]; +} + +export interface TeamMembersViewModel { + members: TeamMemberViewModel[]; +} + +export interface TeamJoinRequestsViewModel { + requests: TeamJoinRequestItemViewModel[]; +} + +export interface DriverTeamViewModel { + teamId: string; + teamName: string; + role: string; + joinedAt: Date; +} + +export interface CreateTeamInput { + name: string; + description?: string; + ownerId: string; +} + +export interface CreateTeamOutput { + teamId: string; + success: boolean; +} + +export interface UpdateTeamInput { + name?: string; + description?: string; + logoUrl?: string; +} + +export interface UpdateTeamOutput { + success: boolean; +} + +// Race Types +export interface RaceListItemViewModel { + id: string; + name: string; + leagueId: string; + leagueName: string; + scheduledTime: string; + status: string; + trackName?: string; +} + +export interface AllRacesPageViewModel { + races: RaceListItemViewModel[]; +} + +export interface RaceStatsDto { + totalRaces: number; +} + +// Sponsor Types +export interface GetEntitySponsorshipPricingResultDto { + mainSlotPrice: number; + secondarySlotPrice: number; + currency: string; +} + +export interface SponsorViewModel { + id: string; + name: string; + logoUrl?: string; + websiteUrl?: string; +} + +export interface GetSponsorsOutput { + sponsors: SponsorViewModel[]; +} + +export interface CreateSponsorInput { + name: string; + logoUrl?: string; + websiteUrl?: string; + userId: string; +} + +export interface CreateSponsorOutput { + sponsorId: string; + success: boolean; +} + +export interface SponsorDashboardDTO { + sponsorId: string; + sponsorName: string; + totalSponsorships: number; + activeSponsorships: number; + totalInvestment: number; +} + +export interface SponsorshipDetailViewModel { + id: string; + leagueId: string; + leagueName: string; + seasonId: string; + tier: 'main' | 'secondary'; + status: string; + amount: number; + currency: string; +} + +export interface SponsorSponsorshipsDTO { + sponsorId: string; + sponsorName: string; + sponsorships: SponsorshipDetailViewModel[]; +} + +// Media Types +export interface RequestAvatarGenerationInput { + driverId: string; + style?: string; +} + +export interface RequestAvatarGenerationOutput { + success: boolean; + avatarUrl?: string; + error?: string; +} + +// Analytics Types +export interface RecordPageViewInput { + path: string; + userId?: string; + sessionId?: string; +} + +export interface RecordPageViewOutput { + success: boolean; +} + +export interface RecordEngagementInput { + eventType: string; + eventData?: Record; + userId?: string; + sessionId?: string; +} + +export interface RecordEngagementOutput { + success: boolean; +} + +// Auth Types +export interface LoginParams { + email: string; + password: string; +} + +export interface SignupParams { + email: string; + password: string; + displayName: string; +} + +export interface SessionData { + userId: string; + email: string; + displayName?: string; + driverId?: string; + isAuthenticated: boolean; +} + +// Payments Types +export interface PaymentViewModel { + id: string; + amount: number; + currency: string; + status: string; + createdAt: string; +} + +export interface GetPaymentsOutput { + payments: PaymentViewModel[]; +} + +export interface CreatePaymentInput { + amount: number; + currency: string; + leagueId: string; + driverId: string; + description?: string; +} + +export interface CreatePaymentOutput { + paymentId: string; + success: boolean; +} + +export interface MembershipFeeViewModel { + leagueId: string; + amount: number; + currency: string; + period: string; +} + +export interface MemberPaymentViewModel { + driverId: string; + amount: number; + paidAt: string; + status: string; +} + +export interface GetMembershipFeesOutput { + fees: MembershipFeeViewModel[]; + memberPayments: MemberPaymentViewModel[]; +} + +export interface PrizeViewModel { + id: string; + name: string; + amount: number; + currency: string; + position?: number; +} + +export interface GetPrizesOutput { + prizes: PrizeViewModel[]; +} + +export interface WalletTransactionViewModel { + id: string; + type: 'deposit' | 'withdrawal'; + amount: number; + description?: string; + createdAt: string; +} + +export interface WalletViewModel { + driverId: string; + balance: number; + currency: string; + transactions: WalletTransactionViewModel[]; +} + +export interface GetWalletOutput { + wallet: WalletViewModel; +} + +// ============================================================================ +// Base API Client +// ============================================================================ + +class BaseApiClient { private baseUrl: string; constructor(baseUrl: string) { this.baseUrl = baseUrl; } - private async request(method: string, path: string, data?: object): Promise { + protected async request(method: string, path: string, data?: object): Promise { const headers: HeadersInit = { 'Content-Type': 'application/json', }; @@ -13,6 +495,7 @@ export class ApiClient { const config: RequestInit = { method, headers, + credentials: 'include', // Include cookies for auth }; if (data) { @@ -22,41 +505,441 @@ export class ApiClient { const response = await fetch(`${this.baseUrl}${path}`, config); if (!response.ok) { - // Attempt to read error message from response body - let errorData: any; + let errorData: { message?: string } = { message: response.statusText }; try { errorData = await response.json(); - } catch (e) { - errorData = { message: response.statusText }; + } catch { + // Keep default error message } throw new Error(errorData.message || `API request failed with status ${response.status}`); } const text = await response.text(); - return text ? JSON.parse(text) : undefined; + if (!text) { + return null as T; + } + return JSON.parse(text) as T; } - get(path: string): Promise { + protected get(path: string): Promise { return this.request('GET', path); } - post(path: string, data: object): Promise { + protected post(path: string, data: object): Promise { return this.request('POST', path, data); } - put(path: string, data: object): Promise { + protected put(path: string, data: object): Promise { return this.request('PUT', path, data); } - delete(path: string): Promise { + protected delete(path: string): Promise { return this.request('DELETE', path); } - patch(path: string, data: object): Promise { + protected patch(path: string, data: object): Promise { return this.request('PATCH', path, data); } } -// Instantiate the API client with your backend's base URL -// You might want to get this from an environment variable -export const api = new ApiClient(process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'); +// ============================================================================ +// Domain-Specific API Clients +// ============================================================================ + +class LeaguesApiClient extends BaseApiClient { + constructor(baseUrl: string) { + super(baseUrl); + } + + /** Get all leagues with capacity information */ + getAllWithCapacity(): Promise { + return this.get('/leagues/all-with-capacity'); + } + + /** Get total number of leagues */ + getTotal(): Promise { + return this.get('/leagues/total-leagues'); + } + + /** Get league standings */ + getStandings(leagueId: string): Promise { + return this.get(`/leagues/${leagueId}/standings`); + } + + /** Get league schedule */ + getSchedule(leagueId: string): Promise { + return this.get(`/leagues/${leagueId}/schedule`); + } + + /** Get league stats */ + getStats(leagueId: string): Promise { + return this.get(`/leagues/${leagueId}/stats`); + } + + /** Get league memberships */ + getMemberships(leagueId: string): Promise { + return this.get(`/leagues/${leagueId}/memberships`); + } + + /** Get league join requests */ + getJoinRequests(leagueId: string): Promise { + return this.get(`/leagues/${leagueId}/join-requests`); + } + + /** Approve a join request */ + approveJoinRequest(leagueId: string, requestId: string): Promise<{ success: boolean }> { + return this.post<{ success: boolean }>(`/leagues/${leagueId}/join-requests/approve`, { requestId }); + } + + /** Reject a join request */ + rejectJoinRequest(leagueId: string, requestId: string, reason?: string): Promise<{ success: boolean }> { + return this.post<{ success: boolean }>(`/leagues/${leagueId}/join-requests/reject`, { requestId, reason }); + } + + /** Get league admin permissions */ + getAdminPermissions(leagueId: string, performerDriverId: string): Promise { + return this.get(`/leagues/${leagueId}/permissions/${performerDriverId}`); + } + + /** Get league owner summary */ + getOwnerSummary(leagueId: string, ownerId: string): Promise { + return this.get(`/leagues/${leagueId}/owner-summary/${ownerId}`); + } + + /** Get league full config */ + getConfig(leagueId: string): Promise { + return this.get(`/leagues/${leagueId}/config`); + } + + /** Get league protests */ + getProtests(leagueId: string): Promise { + return this.get(`/leagues/${leagueId}/protests`); + } + + /** Get league seasons */ + getSeasons(leagueId: string): Promise { + return this.get(`/leagues/${leagueId}/seasons`); + } + + /** Get league admin data */ + getAdmin(leagueId: string): Promise { + return this.get(`/leagues/${leagueId}/admin`); + } + + /** Create a new league */ + create(input: CreateLeagueInput): Promise { + return this.post('/leagues', input); + } + + /** Remove a member from league */ + removeMember(leagueId: string, performerDriverId: string, targetDriverId: string): Promise<{ success: boolean }> { + return this.patch<{ success: boolean }>(`/leagues/${leagueId}/members/${targetDriverId}/remove`, { performerDriverId }); + } + + /** Update member role */ + updateMemberRole(leagueId: string, performerDriverId: string, targetDriverId: string, newRole: string): Promise<{ success: boolean }> { + return this.patch<{ success: boolean }>(`/leagues/${leagueId}/members/${targetDriverId}/role`, { performerDriverId, newRole }); + } +} + +class DriversApiClient extends BaseApiClient { + constructor(baseUrl: string) { + super(baseUrl); + } + + /** Get drivers leaderboard */ + getLeaderboard(): Promise { + return this.get('/drivers/leaderboard'); + } + + /** Get total number of drivers */ + getTotal(): Promise { + return this.get('/drivers/total-drivers'); + } + + /** Get current driver (based on session) */ + getCurrent(): Promise { + return this.get('/drivers/current'); + } + + /** Complete driver onboarding */ + completeOnboarding(input: CompleteOnboardingInput): Promise { + return this.post('/drivers/complete-onboarding', input); + } + + /** Get driver registration status for a race */ + getRegistrationStatus(driverId: string, raceId: string): Promise { + return this.get(`/drivers/${driverId}/races/${raceId}/registration-status`); + } +} + +class TeamsApiClient extends BaseApiClient { + constructor(baseUrl: string) { + super(baseUrl); + } + + /** Get all teams */ + getAll(): Promise { + return this.get('/teams/all'); + } + + /** Get team details */ + getDetails(teamId: string): Promise { + return this.get(`/teams/${teamId}`); + } + + /** Get team members */ + getMembers(teamId: string): Promise { + return this.get(`/teams/${teamId}/members`); + } + + /** Get team join requests */ + getJoinRequests(teamId: string): Promise { + return this.get(`/teams/${teamId}/join-requests`); + } + + /** Approve a join request */ + approveJoinRequest(teamId: string, requestId: string): Promise<{ success: boolean }> { + return this.post<{ success: boolean }>(`/teams/${teamId}/join-requests/approve`, { requestId }); + } + + /** Reject a join request */ + rejectJoinRequest(teamId: string, requestId: string, reason?: string): Promise<{ success: boolean }> { + return this.post<{ success: boolean }>(`/teams/${teamId}/join-requests/reject`, { requestId, reason }); + } + + /** Create a new team */ + create(input: CreateTeamInput): Promise { + return this.post('/teams', input); + } + + /** Update team */ + update(teamId: string, input: UpdateTeamInput): Promise { + return this.patch(`/teams/${teamId}`, input); + } + + /** Get driver's team */ + getDriverTeam(driverId: string): Promise { + return this.get(`/teams/driver/${driverId}`); + } +} + +class RacesApiClient extends BaseApiClient { + constructor(baseUrl: string) { + super(baseUrl); + } + + /** Get all races */ + getAll(): Promise { + return this.get('/races/all'); + } + + /** Get total number of races */ + getTotal(): Promise { + return this.get('/races/total-races'); + } +} + +class SponsorsApiClient extends BaseApiClient { + constructor(baseUrl: string) { + super(baseUrl); + } + + /** Get sponsorship pricing */ + getPricing(): Promise { + return this.get('/sponsors/pricing'); + } + + /** Get all sponsors */ + getAll(): Promise { + return this.get('/sponsors'); + } + + /** Create a new sponsor */ + create(input: CreateSponsorInput): Promise { + return this.post('/sponsors', input); + } + + /** Get sponsor dashboard */ + getDashboard(sponsorId: string): Promise { + return this.get(`/sponsors/dashboard/${sponsorId}`); + } + + /** Get sponsor sponsorships */ + getSponsorships(sponsorId: string): Promise { + return this.get(`/sponsors/${sponsorId}/sponsorships`); + } +} + +class MediaApiClient extends BaseApiClient { + constructor(baseUrl: string) { + super(baseUrl); + } + + /** Request avatar generation */ + requestAvatarGeneration(input: RequestAvatarGenerationInput): Promise { + return this.post('/media/avatar/generate', input); + } +} + +class AnalyticsApiClient extends BaseApiClient { + constructor(baseUrl: string) { + super(baseUrl); + } + + /** Record a page view */ + recordPageView(input: RecordPageViewInput): Promise { + return this.post('/analytics/page-view', input); + } + + /** Record an engagement event */ + recordEngagement(input: RecordEngagementInput): Promise { + return this.post('/analytics/engagement', input); + } +} + +class AuthApiClient extends BaseApiClient { + constructor(baseUrl: string) { + super(baseUrl); + } + + /** Sign up with email */ + signup(params: SignupParams): Promise { + return this.post('/auth/signup', params); + } + + /** Login with email */ + login(params: LoginParams): Promise { + return this.post('/auth/login', params); + } + + /** Get current session */ + getSession(): Promise { + return this.get('/auth/session'); + } + + /** Logout */ + logout(): Promise { + return this.post('/auth/logout', {}); + } + + /** Start iRacing auth redirect */ + getIracingAuthUrl(returnTo?: string): string { + const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'; + const params = returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : ''; + return `${baseUrl}/auth/iracing/start${params}`; + } +} + +class PaymentsApiClient extends BaseApiClient { + constructor(baseUrl: string) { + super(baseUrl); + } + + /** Get payments */ + getPayments(leagueId?: string, driverId?: string): Promise { + const params = new URLSearchParams(); + if (leagueId) params.append('leagueId', leagueId); + if (driverId) params.append('driverId', driverId); + const query = params.toString(); + return this.get(`/payments${query ? `?${query}` : ''}`); + } + + /** Create a payment */ + createPayment(input: CreatePaymentInput): Promise { + return this.post('/payments', input); + } + + /** Update payment status */ + updatePaymentStatus(paymentId: string, status: string): Promise<{ success: boolean }> { + return this.patch<{ success: boolean }>('/payments/status', { paymentId, status }); + } + + /** Get membership fees */ + getMembershipFees(leagueId: string): Promise { + return this.get(`/payments/membership-fees?leagueId=${leagueId}`); + } + + /** Upsert membership fee */ + upsertMembershipFee(leagueId: string, amount: number, currency: string, period: string): Promise<{ success: boolean }> { + return this.post<{ success: boolean }>('/payments/membership-fees', { leagueId, amount, currency, period }); + } + + /** Update member payment */ + updateMemberPayment(leagueId: string, driverId: string, amount: number, paidAt: string): Promise<{ success: boolean }> { + return this.patch<{ success: boolean }>('/payments/membership-fees/member-payment', { leagueId, driverId, amount, paidAt }); + } + + /** Get prizes */ + getPrizes(leagueId?: string, seasonId?: string): Promise { + const params = new URLSearchParams(); + if (leagueId) params.append('leagueId', leagueId); + if (seasonId) params.append('seasonId', seasonId); + const query = params.toString(); + return this.get(`/payments/prizes${query ? `?${query}` : ''}`); + } + + /** Create a prize */ + createPrize(name: string, amount: number, currency: string, leagueId: string, position?: number): Promise<{ prizeId: string; success: boolean }> { + return this.post<{ prizeId: string; success: boolean }>('/payments/prizes', { name, amount, currency, leagueId, position }); + } + + /** Award a prize */ + awardPrize(prizeId: string, driverId: string): Promise<{ success: boolean }> { + return this.patch<{ success: boolean }>('/payments/prizes/award', { prizeId, driverId }); + } + + /** Delete a prize */ + deletePrize(prizeId: string): Promise<{ success: boolean }> { + return this.delete<{ success: boolean }>(`/payments/prizes?prizeId=${prizeId}`); + } + + /** Get wallet */ + getWallet(driverId: string): Promise { + return this.get(`/payments/wallets?driverId=${driverId}`); + } + + /** Process wallet transaction */ + processWalletTransaction(driverId: string, type: 'deposit' | 'withdrawal', amount: number, description?: string): Promise<{ success: boolean }> { + return this.post<{ success: boolean }>('/payments/wallets/transactions', { driverId, type, amount, description }); + } +} + +// ============================================================================ +// Main API Client with Domain Namespaces +// ============================================================================ + +class ApiClient { + public readonly leagues: LeaguesApiClient; + public readonly drivers: DriversApiClient; + public readonly teams: TeamsApiClient; + public readonly races: RacesApiClient; + public readonly sponsors: SponsorsApiClient; + public readonly media: MediaApiClient; + public readonly analytics: AnalyticsApiClient; + public readonly auth: AuthApiClient; + public readonly payments: PaymentsApiClient; + + constructor(baseUrl: string) { + this.leagues = new LeaguesApiClient(baseUrl); + this.drivers = new DriversApiClient(baseUrl); + this.teams = new TeamsApiClient(baseUrl); + this.races = new RacesApiClient(baseUrl); + this.sponsors = new SponsorsApiClient(baseUrl); + this.media = new MediaApiClient(baseUrl); + this.analytics = new AnalyticsApiClient(baseUrl); + this.auth = new AuthApiClient(baseUrl); + this.payments = new PaymentsApiClient(baseUrl); + } +} + +// ============================================================================ +// Singleton Instance +// ============================================================================ + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'; + +export const apiClient = new ApiClient(API_BASE_URL); + +// Default export for convenience +export default apiClient; diff --git a/apps/website/lib/app.module.ts b/apps/website/lib/app.module.ts deleted file mode 100644 index 5d7b9da0d..000000000 --- a/apps/website/lib/app.module.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Module } from '@nestjs/common'; -import { LeagueModule } from './modules/league/LeagueModule'; -import { DriverModule } from './modules/driver/DriverModule'; -import { TeamModule } from './modules/team/TeamModule'; -import { RaceModule } from './modules/race/RaceModule'; -import { SponsorModule } from './modules/sponsor/SponsorModule'; -import { AuthModule } from './modules/auth/AuthModule'; -import { MediaModule } from './modules/media/MediaModule'; -import { AnalyticsModule } from './modules/analytics/AnalyticsModule'; -import { LoggingModule } from './modules/logging/LoggingModule'; - -@Module({ - imports: [ - LoggingModule, - LeagueModule, - DriverModule, - TeamModule, - RaceModule, - SponsorModule, - AuthModule, - MediaModule, - AnalyticsModule, - ], -}) -export class AppModule {} \ No newline at end of file diff --git a/apps/website/lib/currentDriver.ts b/apps/website/lib/currentDriver.ts index 536984210..51ddfe1e6 100644 --- a/apps/website/lib/currentDriver.ts +++ b/apps/website/lib/currentDriver.ts @@ -6,7 +6,7 @@ import { useAuth } from '@/lib/auth/AuthContext'; * Returns the effective driver ID for the current session. * * Prefers the authenticated user's primaryDriverId when available, - * otherwise falls back to the demo default used across the alpha site. + * otherwise returns an empty string (user must log in to have a driver). */ export function useEffectiveDriverId(): string { const { session } = useAuth(); @@ -16,36 +16,11 @@ export function useEffectiveDriverId(): string { } | undefined; - // In alpha mode, if the user has no bound driver yet, fall back to the - // first seeded driver from the in-memory repository instead of a hardcoded ID. + // Return the user's primary driver ID if available if (user?.primaryDriverId) { return user.primaryDriverId; } - try { - // Lazy-load to avoid importing DI facade at module evaluation time - const { getDriverRepository } = - require('./di-container') as typeof import('./di-container'); - const repo = getDriverRepository(); - - interface DriverRepositoryWithSyncFindAll { - findAllSync?: () => Array<{ id: string }>; - } - - // In alpha/demo mode the in-memory repository exposes a synchronous finder; - // access it via a safe dynamic lookup to keep typing compatible with the port. - const repoWithSync = repo as DriverRepositoryWithSyncFindAll; - const allDrivers = repoWithSync.findAllSync?.(); - if (Array.isArray(allDrivers) && allDrivers.length > 0) { - const firstDriver = allDrivers[0]; - if (firstDriver) { - return firstDriver.id; - } - } - } catch { - // Ignore and fall back to legacy default below - } - - // Legacy fallback: preserved only as a last resort for demo + // No driver ID available - user needs to log in or complete onboarding return ''; } \ No newline at end of file diff --git a/apps/website/lib/di-setup.ts b/apps/website/lib/di-setup.ts deleted file mode 100644 index e80714c98..000000000 --- a/apps/website/lib/di-setup.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { NestFactory } from '@nestjs/core'; -import { INestApplicationContext } from '@nestjs/common'; -import { AppModule } from './app.module'; - -let appContext: INestApplicationContext | null = null; - -export async function initializeDIContainer(): Promise { - if (appContext) { - return; // Already initialized - } - - appContext = await NestFactory.createApplicationContext(AppModule); -} - -export function getDIContainer(): INestApplicationContext { - if (!appContext) { - throw new Error('DI container not initialized. Call initializeDIContainer() first.'); - } - return appContext; -} - -export async function getService(token: string | symbol): Promise { - const container = getDIContainer(); - return container.get(token); -} \ No newline at end of file diff --git a/apps/website/lib/leagueMembership.ts b/apps/website/lib/leagueMembership.ts index 059c1396c..0c7c00388 100644 --- a/apps/website/lib/leagueMembership.ts +++ b/apps/website/lib/leagueMembership.ts @@ -1,81 +1,82 @@ 'use client'; -import type { - LeagueMembership as DomainLeagueMembership, - MembershipRole, - MembershipStatus, -} from '@gridpilot/racing/domain/entities/LeagueMembership'; +import { apiClient } from '@/lib/apiClient'; /** - * Lightweight league membership model mirroring the domain type but with - * a stringified joinedAt for easier UI formatting. + * Membership role types - these are defined locally to avoid core dependencies */ -export interface LeagueMembership extends Omit { +export type MembershipRole = 'owner' | 'admin' | 'steward' | 'member'; +export type MembershipStatus = 'active' | 'inactive' | 'pending'; + +/** + * Lightweight league membership model for UI. + */ +export interface LeagueMembership { + id: string; + leagueId: string; + driverId: string; + role: MembershipRole; + status: MembershipStatus; joinedAt: string; } +// In-memory cache for memberships (populated via API calls) const leagueMemberships = new Map(); /** - * Initialize league memberships once from the in-memory league membership repository - * that is seeded via the static racing seed in the DI container. - * - * This avoids depending on raw testing-support seed exports and keeps all demo - * membership data flowing through the same in-memory repositories used elsewhere. + * Get a specific membership from cache. */ -(async function initializeLeagueMembershipsFromRepository() { - if (leagueMemberships.size > 0) { - return; - } - - try { - const { getLeagueRepository, getLeagueMembershipRepository } = await import('./di-container'); - const leagueRepo = getLeagueRepository(); - const membershipRepo = getLeagueMembershipRepository(); - - const allLeagues = await leagueRepo.findAll(); - const byLeague = new Map(); - - for (const league of allLeagues) { - const memberships = await membershipRepo.getLeagueMembers(league.id); - - const mapped: LeagueMembership[] = memberships.map((membership) => ({ - id: membership.id, - leagueId: membership.leagueId, - driverId: membership.driverId, - role: membership.role, - status: membership.status, - joinedAt: - membership.joinedAt instanceof Date - ? membership.joinedAt.toISOString() - : new Date().toISOString(), - })); - - byLeague.set(league.id, mapped); - } - - for (const [leagueId, list] of byLeague.entries()) { - leagueMemberships.set(leagueId, list); - } - } catch (error) { - // In alpha/demo mode we tolerate failures here; callers will see empty memberships. - // eslint-disable-next-line no-console - console.error('Failed to initialize league memberships from repository', error); - } -})(); - export function getMembership(leagueId: string, driverId: string): LeagueMembership | null { const list = leagueMemberships.get(leagueId); if (!list) return null; return list.find((m) => m.driverId === driverId) ?? null; } +/** + * Get all members of a league from cache. + */ export function getLeagueMembers(leagueId: string): LeagueMembership[] { return [...(leagueMemberships.get(leagueId) ?? [])]; } /** - * Derive a driver's primary league from in-memory league memberships. + * Fetch and cache memberships for a league via API. + */ +export async function fetchLeagueMemberships(leagueId: string): Promise { + try { + const result = await apiClient.leagues.getMemberships(leagueId); + const memberships: LeagueMembership[] = result.members.map(member => ({ + id: `${member.driverId}-${leagueId}`, // Generate ID since API doesn't provide it + leagueId, + driverId: member.driverId, + role: member.role as MembershipRole, + status: 'active' as MembershipStatus, // Assume active since API returns current members + joinedAt: member.joinedAt, + })); + setLeagueMemberships(leagueId, memberships); + return memberships; + } catch (error) { + console.error('Failed to fetch league memberships:', error); + return []; + } +} + +/** + * Set memberships in cache (for use after API calls). + */ +export function setLeagueMemberships(leagueId: string, memberships: LeagueMembership[]): void { + leagueMemberships.set(leagueId, memberships); +} + +/** + * Clear cached memberships for a league. + */ +export function clearLeagueMemberships(leagueId: string): void { + leagueMemberships.delete(leagueId); +} + +/** + * Derive a driver's primary league from cached memberships. * Prefers any active membership and returns the first matching league. */ export function getPrimaryLeagueIdForDriver(driverId: string): string | null { @@ -87,10 +88,11 @@ export function getPrimaryLeagueIdForDriver(driverId: string): string | null { return null; } +/** + * Check if a driver is owner or admin of a league. + */ export function isOwnerOrAdmin(leagueId: string, driverId: string): boolean { const membership = getMembership(leagueId, driverId); if (!membership) return false; return membership.role === 'owner' || membership.role === 'admin'; -} - -export type { MembershipRole, MembershipStatus }; \ No newline at end of file +} \ No newline at end of file diff --git a/apps/website/lib/leagueRoles.ts b/apps/website/lib/leagueRoles.ts index fab1af73c..55bacc3d8 100644 --- a/apps/website/lib/leagueRoles.ts +++ b/apps/website/lib/leagueRoles.ts @@ -1,5 +1,7 @@ -import type { MembershipRole } from '@gridpilot/racing/domain/entities/LeagueMembership'; - +/** + * League role types - defined locally to avoid core dependencies + */ +export type MembershipRole = 'owner' | 'admin' | 'steward' | 'member'; export type LeagueRole = MembershipRole; export function isLeagueOwnerRole(role: LeagueRole): boolean { diff --git a/apps/website/lib/leagueWizardService.ts b/apps/website/lib/leagueWizardService.ts index 9649a4c85..1add1a36c 100644 --- a/apps/website/lib/leagueWizardService.ts +++ b/apps/website/lib/leagueWizardService.ts @@ -1,20 +1,64 @@ -import type { - LeagueConfigFormModel, -} from '@gridpilot/racing/application'; -import type { - CreateLeagueWithSeasonAndScoringCommand, - CreateLeagueWithSeasonAndScoringResultDTO, -} from '@gridpilot/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase'; -import { - getDriverRepository, - getCreateLeagueWithSeasonAndScoringUseCase, -} from '@/lib/di-container'; -import { LeagueName } from '@gridpilot/racing/domain/value-objects/LeagueName'; -import { LeagueDescription } from '@gridpilot/racing/domain/value-objects/LeagueDescription'; -import { GameConstraints } from '@gridpilot/racing/domain/value-objects/GameConstraints'; +/** + * League Wizard Service - Refactored to use API client + * + * This service handles league creation wizard logic without direct core dependencies. + */ + +import { apiClient } from '@/lib/apiClient'; export type WizardStep = 1 | 2 | 3 | 4 | 5 | 6 | 7; +export interface LeagueConfigFormModel { + leagueId?: string; + basics: { + name: string; + description?: string; + visibility: 'public' | 'private' | 'unlisted'; + gameId: string; + }; + structure: { + mode: 'solo' | 'fixedTeams'; + maxDrivers?: number; + maxTeams?: number; + driversPerTeam?: number; + }; + championships: { + enableDriverChampionship: boolean; + enableTeamChampionship: boolean; + enableNationsChampionship: boolean; + enableTrophyChampionship: boolean; + }; + scoring: { + patternId?: string; + customScoringEnabled?: boolean; + }; + dropPolicy: { + strategy: 'none' | 'bestNResults' | 'dropWorstN'; + n?: number; + }; + timings: { + practiceMinutes?: number; + qualifyingMinutes?: number; + sprintRaceMinutes?: number; + mainRaceMinutes?: number; + sessionCount?: number; + roundsPlanned?: number; + raceDayOfWeek?: number; + raceTimeUtc?: string; + }; + stewarding: { + decisionMode: 'owner_only' | 'admin_vote' | 'steward_panel'; + requiredVotes?: number; + requireDefense: boolean; + defenseTimeLimit: number; + voteTimeLimit: number; + protestDeadlineHours: number; + stewardingClosesHours: number; + notifyAccusedOnProtest: boolean; + notifyOnVoteRequired: boolean; + }; +} + export interface WizardErrors { basics?: { name?: string; @@ -51,16 +95,18 @@ export function validateLeagueWizardStep( if (step === 1) { const basicsErrors: NonNullable = {}; - // Use LeagueName value object for validation - const nameValidation = LeagueName.validate(form.basics.name); - if (!nameValidation.valid && nameValidation.error) { - basicsErrors.name = nameValidation.error; + // Basic name validation + if (!form.basics.name || form.basics.name.trim().length === 0) { + basicsErrors.name = 'League name is required'; + } else if (form.basics.name.length < 3) { + basicsErrors.name = 'League name must be at least 3 characters'; + } else if (form.basics.name.length > 100) { + basicsErrors.name = 'League name must be less than 100 characters'; } - // Use LeagueDescription value object for validation - const descValidation = LeagueDescription.validate(form.basics.description ?? ''); - if (!descValidation.valid && descValidation.error) { - basicsErrors.description = descValidation.error; + // Description validation + if (form.basics.description && form.basics.description.length > 500) { + basicsErrors.description = 'Description must be less than 500 characters'; } if (Object.keys(basicsErrors).length > 0) { @@ -84,47 +130,23 @@ export function validateLeagueWizardStep( // Step 3: Structure (solo vs teams) if (step === 3) { const structureErrors: NonNullable = {}; - const gameConstraints = GameConstraints.forGame(form.basics.gameId); if (form.structure.mode === 'solo') { if (!form.structure.maxDrivers || form.structure.maxDrivers <= 0) { structureErrors.maxDrivers = 'Max drivers must be greater than 0 for solo leagues'; - } else { - // Validate against game constraints - const driverValidation = gameConstraints.validateDriverCount( - form.structure.maxDrivers, - ); - if (!driverValidation.valid && driverValidation.error) { - structureErrors.maxDrivers = driverValidation.error; - } + } else if (form.structure.maxDrivers > 100) { + structureErrors.maxDrivers = 'Max drivers cannot exceed 100'; } } else if (form.structure.mode === 'fixedTeams') { if (!form.structure.maxTeams || form.structure.maxTeams <= 0) { structureErrors.maxTeams = 'Max teams must be greater than 0 for team leagues'; - } else { - // Validate against game constraints - const teamValidation = gameConstraints.validateTeamCount( - form.structure.maxTeams, - ); - if (!teamValidation.valid && teamValidation.error) { - structureErrors.maxTeams = teamValidation.error; - } } if (!form.structure.driversPerTeam || form.structure.driversPerTeam <= 0) { structureErrors.driversPerTeam = 'Drivers per team must be greater than 0'; } - // Validate total driver count - if (form.structure.maxDrivers) { - const driverValidation = gameConstraints.validateDriverCount( - form.structure.maxDrivers, - ); - if (!driverValidation.valid && driverValidation.error) { - structureErrors.maxDrivers = driverValidation.error; - } - } } if (Object.keys(structureErrors).length > 0) { errors.structure = structureErrors; @@ -210,24 +232,27 @@ export function hasWizardErrors(errors: WizardErrors): boolean { }); } +export interface CreateLeagueResult { + leagueId: string; + seasonId?: string; + success: boolean; +} + /** - * Pure mapping from LeagueConfigFormModel to the creation command. - * Driver ownership is handled by the caller. + * Create a league via API. */ -export function buildCreateLeagueCommandFromConfig( +export async function createLeagueFromConfig( form: LeagueConfigFormModel, ownerId: string, -): CreateLeagueWithSeasonAndScoringCommand { +): Promise { const structure = form.structure; let maxDrivers: number; - let maxTeams: number; if (structure.mode === 'solo') { maxDrivers = typeof structure.maxDrivers === 'number' && structure.maxDrivers > 0 ? structure.maxDrivers : 0; - maxTeams = 0; } else { const teams = typeof structure.maxTeams === 'number' && structure.maxTeams > 0 @@ -237,52 +262,23 @@ export function buildCreateLeagueCommandFromConfig( typeof structure.driversPerTeam === 'number' && structure.driversPerTeam > 0 ? structure.driversPerTeam : 0; - maxTeams = teams; maxDrivers = teams > 0 && perTeam > 0 ? teams * perTeam : 0; } - return { + const result = await apiClient.leagues.create({ name: form.basics.name.trim(), description: (form.basics.description ?? '').trim(), - visibility: form.basics.visibility, + isPublic: form.basics.visibility === 'public', + maxMembers: maxDrivers, ownerId, - gameId: form.basics.gameId, - maxDrivers, - maxTeams, - enableDriverChampionship: form.championships.enableDriverChampionship, - enableTeamChampionship: form.championships.enableTeamChampionship, - enableNationsChampionship: form.championships.enableNationsChampionship, - enableTrophyChampionship: form.championships.enableTrophyChampionship, - scoringPresetId: form.scoring.patternId ?? 'custom', + }); + + return { + leagueId: result.leagueId, + success: result.success, }; } -/** - * Thin application-level facade that: - * - pulls the current driver via repository - * - builds the creation command - * - delegates to the create-league use case - */ -export async function createLeagueFromConfig( - form: LeagueConfigFormModel, -): Promise { - const driverRepo = getDriverRepository(); - const drivers = await driverRepo.findAll(); - const currentDriver = drivers[0]; - - if (!currentDriver) { - const error = new Error( - 'No driver profile found. Please create a driver profile first.', - ) as Error & { code?: string }; - error.code = 'NO_DRIVER'; - throw error; - } - - const useCase = getCreateLeagueWithSeasonAndScoringUseCase(); - const command = buildCreateLeagueCommandFromConfig(form, currentDriver.id); - return useCase.execute(command); -} - /** * Apply scoring preset selection and derive timings, returning a new form model. * This mirrors the previous React handler but keeps it in testable, non-UI logic. diff --git a/apps/website/lib/modules/analytics/AnalyticsModule.ts b/apps/website/lib/modules/analytics/AnalyticsModule.ts deleted file mode 100644 index 6dfbf8d7e..000000000 --- a/apps/website/lib/modules/analytics/AnalyticsModule.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module } from '@nestjs/common'; -import { AnalyticsProviders, PAGE_VIEW_REPOSITORY_TOKEN, ENGAGEMENT_REPOSITORY_TOKEN } from './AnalyticsProviders'; - -@Module({ - imports: [], - providers: AnalyticsProviders, - exports: [PAGE_VIEW_REPOSITORY_TOKEN, ENGAGEMENT_REPOSITORY_TOKEN], -}) -export class AnalyticsModule {} \ No newline at end of file diff --git a/apps/website/lib/modules/analytics/AnalyticsProviders.ts b/apps/website/lib/modules/analytics/AnalyticsProviders.ts deleted file mode 100644 index c62f13792..000000000 --- a/apps/website/lib/modules/analytics/AnalyticsProviders.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Provider } from '@nestjs/common'; - -// Import core interfaces -import { IPageViewRepository } from '@gridpilot/analytics/application/repositories/IPageViewRepository'; -import { IEngagementRepository } from '@gridpilot/analytics/domain/repositories/IEngagementRepository'; -import { Logger } from '@gridpilot/shared/logging/Logger'; - -// Import implementations -import { InMemoryPageViewRepository } from '@gridpilot/adapters/analytics/persistence/inmemory/InMemoryPageViewRepository'; -import { InMemoryEngagementRepository } from '@gridpilot/adapters/analytics/persistence/inmemory/InMemoryEngagementRepository'; - -// Import tokens -import { LOGGER_TOKEN } from '../logging/LoggingModule'; - -// Define injection tokens -export const PAGE_VIEW_REPOSITORY_TOKEN = Symbol('IPageViewRepository'); -export const ENGAGEMENT_REPOSITORY_TOKEN = Symbol('IEngagementRepository'); - -export const AnalyticsProviders: Provider[] = [ - { - provide: PAGE_VIEW_REPOSITORY_TOKEN, - useFactory: (logger: Logger) => new InMemoryPageViewRepository(logger), - inject: [LOGGER_TOKEN], - }, - { - provide: ENGAGEMENT_REPOSITORY_TOKEN, - useFactory: (logger: Logger) => new InMemoryEngagementRepository(logger), - inject: [LOGGER_TOKEN], - }, -]; \ No newline at end of file diff --git a/apps/website/lib/modules/auth/AuthModule.ts b/apps/website/lib/modules/auth/AuthModule.ts deleted file mode 100644 index c8f9cf75c..000000000 --- a/apps/website/lib/modules/auth/AuthModule.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module } from '@nestjs/common'; -import { AuthProviders, AUTH_REPOSITORY_TOKEN, USER_REPOSITORY_TOKEN } from './AuthProviders'; - -@Module({ - imports: [], - providers: AuthProviders, - exports: [AUTH_REPOSITORY_TOKEN, USER_REPOSITORY_TOKEN], -}) -export class AuthModule {} \ No newline at end of file diff --git a/apps/website/lib/modules/auth/AuthProviders.ts b/apps/website/lib/modules/auth/AuthProviders.ts deleted file mode 100644 index 35b1c4918..000000000 --- a/apps/website/lib/modules/auth/AuthProviders.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Provider } from '@nestjs/common'; - -// Import core interfaces -import { IAuthRepository } from '@gridpilot/identity/domain/repositories/IAuthRepository'; -import { IUserRepository } from '@gridpilot/identity/domain/repositories/IUserRepository'; -import { Logger } from '@gridpilot/shared/logging/Logger'; - -// Import implementations -import { InMemoryAuthRepository } from '@gridpilot/adapters/identity/persistence/inmemory/InMemoryAuthRepository'; -import { InMemoryUserRepository } from '@gridpilot/adapters/identity/persistence/inmemory/InMemoryUserRepository'; - -// Import tokens -import { LOGGER_TOKEN } from '../logging/LoggingModule'; - -// Define injection tokens -export const AUTH_REPOSITORY_TOKEN = Symbol('IAuthRepository'); -export const USER_REPOSITORY_TOKEN = Symbol('IUserRepository'); - -export const AuthProviders: Provider[] = [ - { - provide: AUTH_REPOSITORY_TOKEN, - useFactory: (logger: Logger) => new InMemoryAuthRepository(logger), - inject: [LOGGER_TOKEN], - }, - { - provide: USER_REPOSITORY_TOKEN, - useFactory: (logger: Logger) => new InMemoryUserRepository(logger), - inject: [LOGGER_TOKEN], - }, -]; \ No newline at end of file diff --git a/apps/website/lib/modules/driver/DriverModule.ts b/apps/website/lib/modules/driver/DriverModule.ts deleted file mode 100644 index 759344ca4..000000000 --- a/apps/website/lib/modules/driver/DriverModule.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module } from '@nestjs/common'; -import { DriverProviders, DRIVER_REPOSITORY_TOKEN } from './DriverProviders'; - -@Module({ - imports: [], - providers: DriverProviders, - exports: [DRIVER_REPOSITORY_TOKEN], -}) -export class DriverModule {} \ No newline at end of file diff --git a/apps/website/lib/modules/driver/DriverProviders.ts b/apps/website/lib/modules/driver/DriverProviders.ts deleted file mode 100644 index d159871c4..000000000 --- a/apps/website/lib/modules/driver/DriverProviders.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Provider } from '@nestjs/common'; - -// Import core interfaces -import { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository'; -import { Logger } from '@gridpilot/shared/logging/Logger'; - -// Import implementations -import { InMemoryDriverRepository } from '@gridpilot/adapters/racing/persistence/inmemory/InMemoryDriverRepository'; - -// Import tokens -import { LOGGER_TOKEN } from '../logging/LoggingModule'; - -// Define injection tokens -export const DRIVER_REPOSITORY_TOKEN = Symbol('IDriverRepository'); - -export const DriverProviders: Provider[] = [ - { - provide: DRIVER_REPOSITORY_TOKEN, - useFactory: (logger: Logger) => new InMemoryDriverRepository(logger), - inject: [LOGGER_TOKEN], - }, -]; \ No newline at end of file diff --git a/apps/website/lib/modules/league/LeagueModule.ts b/apps/website/lib/modules/league/LeagueModule.ts deleted file mode 100644 index 7fe821308..000000000 --- a/apps/website/lib/modules/league/LeagueModule.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module } from '@nestjs/common'; -import { LeagueProviders, GET_LEAGUE_STANDINGS_USE_CASE_TOKEN } from './LeagueProviders'; - -@Module({ - imports: [], - providers: LeagueProviders, - exports: [GET_LEAGUE_STANDINGS_USE_CASE_TOKEN], -}) -export class LeagueModule {} \ No newline at end of file diff --git a/apps/website/lib/modules/league/LeagueProviders.ts b/apps/website/lib/modules/league/LeagueProviders.ts deleted file mode 100644 index c13cc310b..000000000 --- a/apps/website/lib/modules/league/LeagueProviders.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Provider } from '@nestjs/common'; - -// Import core interfaces -import { GetLeagueStandingsUseCase } from '@gridpilot/league/application/use-cases/GetLeagueStandingsUseCase'; -import { ILeagueStandingsRepository } from '@gridpilot/league/application/ports/ILeagueStandingsRepository'; -import { Logger } from '@gridpilot/shared/logging/Logger'; - -// Import implementations -import { GetLeagueStandingsUseCaseImpl } from '@gridpilot/league/application/use-cases/GetLeagueStandingsUseCaseImpl'; -import { InMemoryLeagueStandingsRepository } from '@gridpilot/adapters/league/persistence/inmemory/InMemoryLeagueStandingsRepository'; - -// Import tokens -import { LOGGER_TOKEN } from '../logging/LoggingModule'; - -// Define injection tokens -export const GET_LEAGUE_STANDINGS_USE_CASE_TOKEN = Symbol('GetLeagueStandingsUseCase'); -export const LEAGUE_STANDINGS_REPOSITORY_TOKEN = Symbol('ILeagueStandingsRepository'); - -export const LeagueProviders: Provider[] = [ - { - provide: GET_LEAGUE_STANDINGS_USE_CASE_TOKEN, - useFactory: (repository: ILeagueStandingsRepository, logger: Logger) => new GetLeagueStandingsUseCaseImpl(repository), - inject: [LEAGUE_STANDINGS_REPOSITORY_TOKEN, LOGGER_TOKEN], - }, - { - provide: LEAGUE_STANDINGS_REPOSITORY_TOKEN, - useFactory: (logger: Logger) => new InMemoryLeagueStandingsRepository(logger), - inject: [LOGGER_TOKEN], - }, -]; \ No newline at end of file diff --git a/apps/website/lib/modules/logging/LoggingModule.ts b/apps/website/lib/modules/logging/LoggingModule.ts deleted file mode 100644 index a025b6d50..000000000 --- a/apps/website/lib/modules/logging/LoggingModule.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Global, Module } from '@nestjs/common'; -import { Logger } from '@gridpilot/shared/logging/Logger'; -import { ConsoleLogger } from '@gridpilot/adapters/logging/ConsoleLogger'; - -export const LOGGER_TOKEN = Symbol('Logger'); - -@Global() -@Module({ - providers: [ - { - provide: LOGGER_TOKEN, - useClass: ConsoleLogger, - }, - ], - exports: [LOGGER_TOKEN], -}) -export class LoggingModule {} \ No newline at end of file diff --git a/apps/website/lib/modules/media/MediaModule.ts b/apps/website/lib/modules/media/MediaModule.ts deleted file mode 100644 index 28c02673a..000000000 --- a/apps/website/lib/modules/media/MediaModule.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module } from '@nestjs/common'; -import { MediaProviders, AVATAR_GENERATION_REPOSITORY_TOKEN } from './MediaProviders'; - -@Module({ - imports: [], - providers: MediaProviders, - exports: [AVATAR_GENERATION_REPOSITORY_TOKEN], -}) -export class MediaModule {} \ No newline at end of file diff --git a/apps/website/lib/modules/media/MediaProviders.ts b/apps/website/lib/modules/media/MediaProviders.ts deleted file mode 100644 index 705603b27..000000000 --- a/apps/website/lib/modules/media/MediaProviders.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Provider } from '@nestjs/common'; - -// Import core interfaces -import { IAvatarGenerationRepository } from '@gridpilot/media/domain/repositories/IAvatarGenerationRepository'; -import { Logger } from '@gridpilot/shared/logging/Logger'; - -// Import implementations -import { InMemoryAvatarGenerationRepository } from '@gridpilot/adapters/media/persistence/inmemory/InMemoryAvatarGenerationRepository'; - -// Import tokens -import { LOGGER_TOKEN } from '../logging/LoggingModule'; - -// Define injection tokens -export const AVATAR_GENERATION_REPOSITORY_TOKEN = Symbol('IAvatarGenerationRepository'); - -export const MediaProviders: Provider[] = [ - { - provide: AVATAR_GENERATION_REPOSITORY_TOKEN, - useFactory: (logger: Logger) => new InMemoryAvatarGenerationRepository(logger), - inject: [LOGGER_TOKEN], - }, -]; \ No newline at end of file diff --git a/apps/website/lib/modules/race/RaceModule.ts b/apps/website/lib/modules/race/RaceModule.ts deleted file mode 100644 index 4e86c6dd2..000000000 --- a/apps/website/lib/modules/race/RaceModule.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module } from '@nestjs/common'; -import { RaceProviders, RACE_REPOSITORY_TOKEN } from './RaceProviders'; - -@Module({ - imports: [], - providers: RaceProviders, - exports: [RACE_REPOSITORY_TOKEN], -}) -export class RaceModule {} \ No newline at end of file diff --git a/apps/website/lib/modules/race/RaceProviders.ts b/apps/website/lib/modules/race/RaceProviders.ts deleted file mode 100644 index de72cb8f9..000000000 --- a/apps/website/lib/modules/race/RaceProviders.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Provider } from '@nestjs/common'; - -// Import core interfaces -import { IRaceRepository } from '@gridpilot/racing/domain/repositories/IRaceRepository'; -import { Logger } from '@gridpilot/shared/logging/Logger'; - -// Import implementations -import { InMemoryRaceRepository } from '@gridpilot/adapters/racing/persistence/inmemory/InMemoryRaceRepository'; - -// Import tokens -import { LOGGER_TOKEN } from '../logging/LoggingModule'; - -// Define injection tokens -export const RACE_REPOSITORY_TOKEN = Symbol('IRaceRepository'); - -export const RaceProviders: Provider[] = [ - { - provide: RACE_REPOSITORY_TOKEN, - useFactory: (logger: Logger) => new InMemoryRaceRepository(logger), - inject: [LOGGER_TOKEN], - }, -]; \ No newline at end of file diff --git a/apps/website/lib/modules/sponsor/SponsorModule.ts b/apps/website/lib/modules/sponsor/SponsorModule.ts deleted file mode 100644 index 7719451dd..000000000 --- a/apps/website/lib/modules/sponsor/SponsorModule.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module } from '@nestjs/common'; -import { SponsorProviders, SPONSOR_REPOSITORY_TOKEN } from './SponsorProviders'; - -@Module({ - imports: [], - providers: SponsorProviders, - exports: [SPONSOR_REPOSITORY_TOKEN], -}) -export class SponsorModule {} \ No newline at end of file diff --git a/apps/website/lib/modules/sponsor/SponsorProviders.ts b/apps/website/lib/modules/sponsor/SponsorProviders.ts deleted file mode 100644 index 9804c4b45..000000000 --- a/apps/website/lib/modules/sponsor/SponsorProviders.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Provider } from '@nestjs/common'; - -// Import core interfaces -import { ISponsorRepository } from '@gridpilot/racing/domain/repositories/ISponsorRepository'; -import { Logger } from '@gridpilot/shared/logging/Logger'; - -// Import implementations -import { InMemorySponsorRepository } from '@gridpilot/adapters/racing/persistence/inmemory/InMemorySponsorRepository'; - -// Import tokens -import { LOGGER_TOKEN } from '../logging/LoggingModule'; - -// Define injection tokens -export const SPONSOR_REPOSITORY_TOKEN = Symbol('ISponsorRepository'); - -export const SponsorProviders: Provider[] = [ - { - provide: SPONSOR_REPOSITORY_TOKEN, - useFactory: (logger: Logger) => new InMemorySponsorRepository(logger), - inject: [LOGGER_TOKEN], - }, -]; \ No newline at end of file diff --git a/apps/website/lib/modules/team/TeamModule.ts b/apps/website/lib/modules/team/TeamModule.ts deleted file mode 100644 index 09af74e5d..000000000 --- a/apps/website/lib/modules/team/TeamModule.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TeamProviders, TEAM_REPOSITORY_TOKEN } from './TeamProviders'; - -@Module({ - imports: [], - providers: TeamProviders, - exports: [TEAM_REPOSITORY_TOKEN], -}) -export class TeamModule {} \ No newline at end of file diff --git a/apps/website/lib/modules/team/TeamProviders.ts b/apps/website/lib/modules/team/TeamProviders.ts deleted file mode 100644 index 9cf55b2e0..000000000 --- a/apps/website/lib/modules/team/TeamProviders.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Provider } from '@nestjs/common'; - -// Import core interfaces -import { ITeamRepository } from '@gridpilot/racing/domain/repositories/ITeamRepository'; -import { Logger } from '@gridpilot/shared/logging/Logger'; - -// Import implementations -import { InMemoryTeamRepository } from '@gridpilot/adapters/racing/persistence/inmemory/InMemoryTeamRepository'; - -// Import tokens -import { LOGGER_TOKEN } from '../logging/LoggingModule'; - -// Define injection tokens -export const TEAM_REPOSITORY_TOKEN = Symbol('ITeamRepository'); - -export const TeamProviders: Provider[] = [ - { - provide: TEAM_REPOSITORY_TOKEN, - useFactory: (logger: Logger) => new InMemoryTeamRepository(logger), - inject: [LOGGER_TOKEN], - }, -]; \ No newline at end of file diff --git a/apps/website/lib/presenters/AllLeaguesWithCapacityAndScoringPresenter.ts b/apps/website/lib/presenters/AllLeaguesWithCapacityAndScoringPresenter.ts index 8e53c7e3d..2463340e3 100644 --- a/apps/website/lib/presenters/AllLeaguesWithCapacityAndScoringPresenter.ts +++ b/apps/website/lib/presenters/AllLeaguesWithCapacityAndScoringPresenter.ts @@ -1,9 +1,89 @@ -import type { - IAllLeaguesWithCapacityAndScoringPresenter, - LeagueEnrichedData, - LeagueSummaryViewModel, - AllLeaguesWithCapacityAndScoringViewModel, -} from '@gridpilot/racing/application/presenters/IAllLeaguesWithCapacityAndScoringPresenter'; +/** + * AllLeaguesWithCapacityAndScoringPresenter - Pure data transformer + * Transforms API response to view model without DI dependencies. + */ + +import { apiClient, type AllLeaguesWithCapacityViewModel } from '@/lib/apiClient'; + +export interface LeagueScoringViewModel { + gameId: string; + gameName: string; + primaryChampionshipType: string; + scoringPresetId: string; + scoringPresetName: string; + dropPolicySummary: string; + scoringPatternSummary: string; +} + +export interface LeagueSummaryViewModel { + id: string; + name: string; + description?: string | undefined; + ownerId: string; + createdAt: string; + maxDrivers: number; + usedDriverSlots: number; + maxTeams: number; + usedTeamSlots: number; + structureSummary: string; + scoringPatternSummary: string; + timingSummary: string; + scoring: LeagueScoringViewModel; +} + +export interface AllLeaguesWithCapacityAndScoringViewModel { + leagues: LeagueSummaryViewModel[]; + totalCount: number; +} + +export interface IAllLeaguesWithCapacityAndScoringPresenter { + reset(): void; + getViewModel(): AllLeaguesWithCapacityAndScoringViewModel | null; +} + +/** + * Transform API response to view model + */ +function transformApiResponse(apiResponse: AllLeaguesWithCapacityViewModel): AllLeaguesWithCapacityAndScoringViewModel { + const leagueItems: LeagueSummaryViewModel[] = apiResponse.leagues.map((league) => { + const maxDrivers = league.maxMembers; + const usedDriverSlots = league.memberCount; + const structureSummary = `Solo • ${maxDrivers} drivers`; + const timingSummary = '30 min Quali • 40 min Race'; + const scoringPatternSummary = 'Custom • All results count'; + + const scoringSummary: LeagueScoringViewModel = { + gameId: 'unknown', + gameName: 'Unknown', + primaryChampionshipType: 'driver', + scoringPresetId: 'custom', + scoringPresetName: 'Custom', + dropPolicySummary: 'All results count', + scoringPatternSummary, + }; + + return { + id: league.id, + name: league.name, + description: league.description, + ownerId: league.ownerId, + createdAt: new Date().toISOString(), // Would need from API + maxDrivers, + usedDriverSlots, + maxTeams: 0, + usedTeamSlots: 0, + structureSummary, + scoringPatternSummary, + timingSummary, + scoring: scoringSummary, + }; + }); + + return { + leagues: leagueItems, + totalCount: leagueItems.length, + }; +} export class AllLeaguesWithCapacityAndScoringPresenter implements IAllLeaguesWithCapacityAndScoringPresenter { private viewModel: AllLeaguesWithCapacityAndScoringViewModel | null = null; @@ -12,116 +92,20 @@ export class AllLeaguesWithCapacityAndScoringPresenter implements IAllLeaguesWit this.viewModel = null; } - present(enrichedLeagues: LeagueEnrichedData[]): void { - const leagueItems: LeagueSummaryViewModel[] = enrichedLeagues.map((data) => { - const { league, usedDriverSlots, season, scoringConfig, game, preset } = data; - - const configuredMaxDrivers = league.settings.maxDrivers ?? usedDriverSlots; - const safeMaxDrivers = Math.max(configuredMaxDrivers, usedDriverSlots); - - const structureSummary = `Solo • ${safeMaxDrivers} drivers`; - - const qualifyingMinutes = 30; - const mainRaceMinutes = - typeof league.settings.sessionDuration === 'number' - ? league.settings.sessionDuration - : 40; - const timingSummary = `${qualifyingMinutes} min Quali • ${mainRaceMinutes} min Race`; - - let scoringPatternSummary: string | null = null; - let scoringSummary: LeagueSummaryViewModel['scoring']; - - if (season && scoringConfig && game) { - const dropPolicySummary = - preset?.dropPolicySummary ?? this.deriveDropPolicySummary(scoringConfig); - const primaryChampionshipType = - preset?.primaryChampionshipType ?? - (scoringConfig.championships[0]?.type ?? 'driver'); - - const scoringPresetName = preset?.name ?? 'Custom'; - scoringPatternSummary = `${scoringPresetName} • ${dropPolicySummary}`; - - scoringSummary = { - gameId: game.id, - gameName: game.name, - primaryChampionshipType, - scoringPresetId: scoringConfig.scoringPresetId ?? 'custom', - scoringPresetName, - dropPolicySummary, - scoringPatternSummary, - }; - } else { - const dropPolicySummary = 'All results count'; - const scoringPresetName = 'Custom'; - scoringPatternSummary = scoringPatternSummary ?? `${scoringPresetName} • ${dropPolicySummary}`; - - scoringSummary = { - gameId: 'unknown', - gameName: 'Unknown', - primaryChampionshipType: 'driver', - scoringPresetId: 'custom', - scoringPresetName, - dropPolicySummary, - scoringPatternSummary, - }; - } - - const base: LeagueSummaryViewModel = { - id: league.id, - name: league.name, - description: league.description, - ownerId: league.ownerId, - createdAt: league.createdAt.toISOString(), - maxDrivers: safeMaxDrivers, - usedDriverSlots, - // Team capacity is not yet modeled here; use zero for now to satisfy strict typing. - maxTeams: 0, - usedTeamSlots: 0, - structureSummary, - scoringPatternSummary: scoringPatternSummary ?? '', - timingSummary, - scoring: scoringSummary, - }; - - return base; - }); - - this.viewModel = { - leagues: leagueItems, - totalCount: leagueItems.length, - }; + async fetchAndPresent(): Promise { + const apiResponse = await apiClient.leagues.getAllWithCapacity(); + this.viewModel = transformApiResponse(apiResponse); } getViewModel(): AllLeaguesWithCapacityAndScoringViewModel | null { return this.viewModel; } +} - private deriveDropPolicySummary(config: { - championships: Array<{ - dropScorePolicy: { strategy: string; count?: number; dropCount?: number }; - }>; - }): string { - const championship = config.championships[0]; - if (!championship) { - return 'All results count'; - } - - const policy = championship.dropScorePolicy; - if (!policy || policy.strategy === 'none') { - return 'All results count'; - } - - if (policy.strategy === 'bestNResults' && typeof policy.count === 'number') { - return `Best ${policy.count} results count`; - } - - if ( - policy.strategy === 'dropWorstN' && - typeof policy.dropCount === 'number' - ) { - return `Worst ${policy.dropCount} results are dropped`; - } - - return 'Custom drop score rules'; - } +/** + * Convenience function to fetch and transform all leagues + */ +export async function fetchAllLeaguesWithCapacityAndScoring(): Promise { + const apiResponse = await apiClient.leagues.getAllWithCapacity(); + return transformApiResponse(apiResponse); } \ No newline at end of file diff --git a/apps/website/lib/presenters/AllTeamsPresenter.ts b/apps/website/lib/presenters/AllTeamsPresenter.ts index f2f1861fe..1e73c064e 100644 --- a/apps/website/lib/presenters/AllTeamsPresenter.ts +++ b/apps/website/lib/presenters/AllTeamsPresenter.ts @@ -1,9 +1,56 @@ -import type { - IAllTeamsPresenter, - TeamListItemViewModel, - AllTeamsViewModel, - AllTeamsResultDTO, -} from '@gridpilot/racing/application/presenters/IAllTeamsPresenter'; +/** + * AllTeamsPresenter - Pure data transformer + * Transforms API response to view model without DI dependencies. + */ + +import { apiClient, type AllTeamsViewModel as ApiAllTeamsViewModel } from '@/lib/apiClient'; + +export interface TeamListItemViewModel { + id: string; + name: string; + tag?: string | undefined; + description?: string | undefined; + memberCount: number; + logoUrl?: string | undefined; + rating?: number | undefined; +} + +export interface AllTeamsViewModel { + teams: TeamListItemViewModel[]; + totalCount: number; +} + +export interface IAllTeamsPresenter { + reset(): void; + getViewModel(): AllTeamsViewModel | null; +} + +/** + * Transform API response to view model + */ +function transformApiResponse(apiResponse: ApiAllTeamsViewModel): AllTeamsViewModel { + const teamItems: TeamListItemViewModel[] = apiResponse.teams.map((team) => { + const viewModel: TeamListItemViewModel = { + id: team.id, + name: team.name, + memberCount: team.memberCount ?? 0, + }; + + if (team.logoUrl) { + viewModel.logoUrl = team.logoUrl; + } + if (team.rating) { + viewModel.rating = team.rating; + } + + return viewModel; + }); + + return { + teams: teamItems, + totalCount: teamItems.length, + }; +} export class AllTeamsPresenter implements IAllTeamsPresenter { private viewModel: AllTeamsViewModel | null = null; @@ -12,23 +59,20 @@ export class AllTeamsPresenter implements IAllTeamsPresenter { this.viewModel = null; } - present(input: AllTeamsResultDTO): void { - const teamItems: TeamListItemViewModel[] = input.teams.map((team) => ({ - id: team.id, - name: team.name, - tag: team.tag, - description: team.description, - memberCount: team.memberCount ?? 0, - leagues: team.leagues, - })); - - this.viewModel = { - teams: teamItems, - totalCount: teamItems.length, - }; + async fetchAndPresent(): Promise { + const apiResponse = await apiClient.teams.getAll(); + this.viewModel = transformApiResponse(apiResponse); } getViewModel(): AllTeamsViewModel | null { return this.viewModel; } +} + +/** + * Convenience function to fetch and transform all teams + */ +export async function fetchAllTeams(): Promise { + const apiResponse = await apiClient.teams.getAll(); + return transformApiResponse(apiResponse); } \ No newline at end of file diff --git a/apps/website/lib/presenters/DriverTeamPresenter.ts b/apps/website/lib/presenters/DriverTeamPresenter.ts index bf64b4260..1aeb6ff4b 100644 --- a/apps/website/lib/presenters/DriverTeamPresenter.ts +++ b/apps/website/lib/presenters/DriverTeamPresenter.ts @@ -1,8 +1,59 @@ -import type { - IDriverTeamPresenter, - DriverTeamViewModel, - DriverTeamResultDTO, -} from '@gridpilot/racing/application/presenters/IDriverTeamPresenter'; +/** + * DriverTeamPresenter - Pure data transformer + * Transforms API response to view model without DI dependencies. + */ + +import { apiClient, type DriverTeamViewModel as ApiDriverTeamViewModel } from '@/lib/apiClient'; + +export interface DriverTeamMembershipViewModel { + role: string; + joinedAt: string; + isActive: boolean; +} + +export interface DriverTeamInfoViewModel { + id: string; + name: string; + tag?: string | undefined; + description?: string | undefined; + ownerId: string; + leagues?: string[] | undefined; +} + +export interface DriverTeamViewModel { + team: DriverTeamInfoViewModel; + membership: DriverTeamMembershipViewModel; + isOwner: boolean; + canManage: boolean; +} + +export interface IDriverTeamPresenter { + reset(): void; + getViewModel(): DriverTeamViewModel | null; +} + +/** + * Transform API response to view model + */ +function transformApiResponse(apiResponse: ApiDriverTeamViewModel): DriverTeamViewModel { + const isOwner = false; // Would need team owner info from API + const canManage = apiResponse.role === 'owner' || apiResponse.role === 'manager'; + + return { + team: { + id: apiResponse.teamId, + name: apiResponse.teamName, + ownerId: '', // Would need from API + }, + membership: { + role: apiResponse.role === 'driver' ? 'member' : apiResponse.role, + joinedAt: new Date(apiResponse.joinedAt).toISOString(), + isActive: true, + }, + isOwner, + canManage, + }; +} export class DriverTeamPresenter implements IDriverTeamPresenter { private viewModel: DriverTeamViewModel | null = null; @@ -11,32 +62,27 @@ export class DriverTeamPresenter implements IDriverTeamPresenter { this.viewModel = null; } - present(input: DriverTeamResultDTO): void { - const { team, membership, driverId } = input; - - const isOwner = team.ownerId === driverId; - const canManage = membership.role === 'owner' || membership.role === 'manager'; - - this.viewModel = { - team: { - id: team.id, - name: team.name, - tag: team.tag, - description: team.description, - ownerId: team.ownerId, - leagues: team.leagues, - }, - membership: { - role: membership.role === 'driver' ? 'member' : membership.role, - joinedAt: membership.joinedAt.toISOString(), - isActive: membership.status === 'active', - }, - isOwner, - canManage, - }; + async fetchAndPresent(driverId: string): Promise { + const apiResponse = await apiClient.teams.getDriverTeam(driverId); + if (apiResponse) { + this.viewModel = transformApiResponse(apiResponse); + } else { + this.viewModel = null; + } } getViewModel(): DriverTeamViewModel | null { return this.viewModel; } +} + +/** + * Convenience function to fetch and transform driver's team + */ +export async function fetchDriverTeam(driverId: string): Promise { + const apiResponse = await apiClient.teams.getDriverTeam(driverId); + if (!apiResponse) { + return null; + } + return transformApiResponse(apiResponse); } \ No newline at end of file diff --git a/apps/website/lib/presenters/DriversLeaderboardPresenter.ts b/apps/website/lib/presenters/DriversLeaderboardPresenter.ts index b3ffbbe3f..975f1bed4 100644 --- a/apps/website/lib/presenters/DriversLeaderboardPresenter.ts +++ b/apps/website/lib/presenters/DriversLeaderboardPresenter.ts @@ -1,10 +1,87 @@ -import { SkillLevelService } from '@gridpilot/racing/domain/services/SkillLevelService'; -import type { - IDriversLeaderboardPresenter, - DriverLeaderboardItemViewModel, - DriversLeaderboardViewModel, - DriversLeaderboardResultDTO, -} from '@gridpilot/racing/application/presenters/IDriversLeaderboardPresenter'; +/** + * DriversLeaderboardPresenter - Pure data transformer + * Transforms API response to view model without DI dependencies. + */ + +import { apiClient, type DriversLeaderboardViewModel as ApiDriversLeaderboardViewModel } from '@/lib/apiClient'; + +export type SkillLevel = 'rookie' | 'amateur' | 'pro' | 'elite' | 'legend'; + +export interface DriverLeaderboardItemViewModel { + id: string; + name: string; + rating: number; + skillLevel: SkillLevel; + nationality?: string | undefined; + racesCompleted: number; + wins: number; + podiums: number; + isActive: boolean; + rank: number; + avatarUrl?: string | undefined; +} + +export interface DriversLeaderboardViewModel { + drivers: DriverLeaderboardItemViewModel[]; + totalRaces: number; + totalWins: number; + activeCount: number; +} + +export interface IDriversLeaderboardPresenter { + reset(): void; + getViewModel(): DriversLeaderboardViewModel | null; +} + +/** + * Calculate skill level from rating + */ +function getSkillLevel(rating: number): SkillLevel { + if (rating >= 5000) return 'legend'; + if (rating >= 3500) return 'elite'; + if (rating >= 2000) return 'pro'; + if (rating >= 1000) return 'amateur'; + return 'rookie'; +} + +/** + * Transform API response to view model + */ +function transformApiResponse(apiResponse: ApiDriversLeaderboardViewModel): DriversLeaderboardViewModel { + const items: DriverLeaderboardItemViewModel[] = apiResponse.drivers.map((driver, index) => { + const rating = driver.rating ?? 0; + const skillLevel = getSkillLevel(rating); + + const viewModel: DriverLeaderboardItemViewModel = { + id: driver.id, + name: driver.name, + rating, + skillLevel, + racesCompleted: driver.races ?? 0, + wins: driver.wins ?? 0, + podiums: 0, // API may not provide this, default to 0 + isActive: true, + rank: index + 1, + }; + + if (driver.avatarUrl) { + viewModel.avatarUrl = driver.avatarUrl; + } + + return viewModel; + }); + + const totalRaces = items.reduce((sum, d) => sum + d.racesCompleted, 0); + const totalWins = items.reduce((sum, d) => sum + d.wins, 0); + const activeCount = items.filter((d) => d.isActive).length; + + return { + drivers: items, + totalRaces, + totalWins, + activeCount, + }; +} export class DriversLeaderboardPresenter implements IDriversLeaderboardPresenter { private viewModel: DriversLeaderboardViewModel | null = null; @@ -13,63 +90,20 @@ export class DriversLeaderboardPresenter implements IDriversLeaderboardPresenter this.viewModel = null; } - present(input: DriversLeaderboardResultDTO): void { - const { drivers, rankings, stats, avatarUrls } = input; - const items: DriverLeaderboardItemViewModel[] = drivers.map((driver) => { - const driverStats = stats[driver.id]; - const rating = driverStats?.rating ?? 0; - const wins = driverStats?.wins ?? 0; - const podiums = driverStats?.podiums ?? 0; - const totalRaces = driverStats?.totalRaces ?? 0; - - let effectiveRank = Number.POSITIVE_INFINITY; - if (typeof driverStats?.overallRank === 'number' && driverStats.overallRank > 0) { - effectiveRank = driverStats.overallRank; - } else { - const indexInGlobal = rankings.findIndex((entry) => entry.driverId === driver.id); - if (indexInGlobal !== -1) { - effectiveRank = indexInGlobal + 1; - } - } - - const skillLevel = SkillLevelService.getSkillLevel(rating); - const isActive = rankings.some((r) => r.driverId === driver.id); - - return { - id: driver.id, - name: driver.name, - rating, - skillLevel, - nationality: driver.country, - racesCompleted: totalRaces, - wins, - podiums, - isActive, - rank: effectiveRank, - avatarUrl: avatarUrls[driver.id] ?? '', - }; - }); - - items.sort((a, b) => { - const rankA = Number.isFinite(a.rank) && a.rank > 0 ? a.rank : Number.POSITIVE_INFINITY; - const rankB = Number.isFinite(b.rank) && b.rank > 0 ? b.rank : Number.POSITIVE_INFINITY; - if (rankA !== rankB) return rankA - rankB; - return b.rating - a.rating; - }); - - const totalRaces = items.reduce((sum, d) => sum + d.racesCompleted, 0); - const totalWins = items.reduce((sum, d) => sum + d.wins, 0); - const activeCount = items.filter((d) => d.isActive).length; - - this.viewModel = { - drivers: items, - totalRaces, - totalWins, - activeCount, - }; + async fetchAndPresent(): Promise { + const apiResponse = await apiClient.drivers.getLeaderboard(); + this.viewModel = transformApiResponse(apiResponse); } getViewModel(): DriversLeaderboardViewModel | null { return this.viewModel; } +} + +/** + * Convenience function to fetch and transform drivers leaderboard + */ +export async function fetchDriversLeaderboard(): Promise { + const apiResponse = await apiClient.drivers.getLeaderboard(); + return transformApiResponse(apiResponse); } \ No newline at end of file diff --git a/apps/website/lib/presenters/LeagueAdminPresenter.ts b/apps/website/lib/presenters/LeagueAdminPresenter.ts index e4f5bb7a8..497441e11 100644 --- a/apps/website/lib/presenters/LeagueAdminPresenter.ts +++ b/apps/website/lib/presenters/LeagueAdminPresenter.ts @@ -1,30 +1,28 @@ -import type { League } from '@gridpilot/racing/domain/entities/League'; -import type { Protest } from '@gridpilot/racing/domain/entities/Protest'; -import type { Race } from '@gridpilot/racing/domain/entities/Race'; -import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO'; -import type { LeagueConfigFormModel } from '@gridpilot/racing/application'; -import type { LeagueConfigFormViewModel } from '@gridpilot/racing/application/presenters/ILeagueFullConfigPresenter'; -import { LeagueFullConfigPresenter } from '@/lib/presenters/LeagueFullConfigPresenter'; -import type { MembershipRole } from '@/lib/leagueMembership'; -import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers'; -import { - getLeagueMembershipRepository, - getDriverRepository, - getGetLeagueFullConfigUseCase, - getRaceRepository, - getProtestRepository, - getDriverStats, - getAllDriverRankings, - getListSeasonsForLeagueUseCase, -} from '@/lib/di-container'; +/** + * LeagueAdminPresenter - Pure data transformer + * Transforms API responses to view models without DI dependencies. + * All data fetching is done via apiClient. + */ + +import { apiClient } from '@/lib/apiClient'; +import type { + LeagueJoinRequestViewModel as ApiLeagueJoinRequestViewModel, + LeagueConfigFormModelDto, + LeagueSeasonSummaryViewModel as ApiLeagueSeasonSummaryViewModel, + DriverDTO, +} from '@/lib/apiClient'; + +// ============================================================================ +// View Model Types +// ============================================================================ export interface LeagueJoinRequestViewModel { id: string; leagueId: string; driverId: string; requestedAt: Date; - message?: string; - driver?: DriverDTO; + message?: string | undefined; + driver?: DriverDTO | undefined; } export interface ProtestDriverSummary { @@ -32,7 +30,11 @@ export interface ProtestDriverSummary { } export interface ProtestRaceSummary { - [raceId: string]: Race; + [raceId: string]: { + id: string; + name: string; + scheduledTime: string; + }; } export interface LeagueOwnerSummaryViewModel { @@ -50,13 +52,21 @@ export interface LeagueSummaryViewModel { } export interface LeagueAdminProtestsViewModel { - protests: Protest[]; + protests: Array<{ + id: string; + raceId: string; + complainantId: string; + defendantId: string; + description: string; + status: string; + createdAt: string; + }>; racesById: ProtestRaceSummary; driversById: ProtestDriverSummary; } export interface LeagueAdminConfigViewModel { - form: LeagueConfigFormModel | null; + form: LeagueConfigFormModelDto | null; } export interface LeagueAdminPermissionsViewModel { @@ -68,8 +78,8 @@ export interface LeagueSeasonSummaryViewModel { seasonId: string; name: string; status: string; - startDate?: Date; - endDate?: Date; + startDate?: Date | undefined; + endDate?: Date | undefined; isPrimary: boolean; isParallelActive: boolean; } @@ -81,41 +91,31 @@ export interface LeagueAdminViewModel { protests: LeagueAdminProtestsViewModel; } +export type MembershipRole = 'owner' | 'admin' | 'member'; + +// ============================================================================ +// Data Fetching Functions (using apiClient) +// ============================================================================ + /** - * Load join requests plus requester driver DTOs for a league. + * Load join requests for a league via API. */ export async function loadLeagueJoinRequests(leagueId: string): Promise { - const membershipRepo = getLeagueMembershipRepository(); - const requests = await membershipRepo.getJoinRequests(leagueId); - - const driverRepo = getDriverRepository(); - const uniqueDriverIds = Array.from(new Set(requests.map((r) => r.driverId))); - const driverEntities = await Promise.all(uniqueDriverIds.map((id) => driverRepo.findById(id))); - const driverDtos = driverEntities - .map((driver) => (driver ? EntityMappers.toDriverDTO(driver) : null)) - .filter((dto): dto is DriverDTO => dto !== null); - - const driversById: Record = {}; - for (const dto of driverDtos) { - driversById[dto.id] = dto; - } - - return requests.map((request) => { - const base: LeagueJoinRequestViewModel = { + const requests = await apiClient.leagues.getJoinRequests(leagueId); + + return requests.map((request: ApiLeagueJoinRequestViewModel) => { + const viewModel: LeagueJoinRequestViewModel = { id: request.id, leagueId: request.leagueId, driverId: request.driverId, - requestedAt: request.requestedAt, + requestedAt: new Date(request.requestedAt), }; - const message = request.message; - const driver = driversById[request.driverId]; + if (request.message) { + viewModel.message = request.message; + } - return { - ...base, - ...(typeof message === 'string' && message.length > 0 ? { message } : {}), - ...(driver ? { driver } : {}), - }; + return viewModel; }); } @@ -126,84 +126,49 @@ export async function approveLeagueJoinRequest( leagueId: string, requestId: string ): Promise { - const membershipRepo = getLeagueMembershipRepository(); - const requests = await membershipRepo.getJoinRequests(leagueId); - const request = requests.find((r) => r.id === requestId); - if (!request) { - throw new Error('Join request not found'); - } - - await membershipRepo.saveMembership({ - id: request.id, - leagueId: request.leagueId, - driverId: request.driverId, - role: 'member', - status: 'active', - joinedAt: new Date(), - }); - await membershipRepo.removeJoinRequest(requestId); - + await apiClient.leagues.approveJoinRequest(leagueId, requestId); return loadLeagueJoinRequests(leagueId); } /** - * Reject a league join request (alpha: just remove). + * Reject a league join request. */ export async function rejectLeagueJoinRequest( leagueId: string, requestId: string ): Promise { - const membershipRepo = getLeagueMembershipRepository(); - await membershipRepo.removeJoinRequest(requestId); + await apiClient.leagues.rejectJoinRequest(leagueId, requestId); return loadLeagueJoinRequests(leagueId); } /** - * Compute permissions for a performer on league membership actions. + * Get permissions for a performer on league membership actions. */ export async function getLeagueAdminPermissions( leagueId: string, performerDriverId: string ): Promise { - const membershipRepo = getLeagueMembershipRepository(); - const performer = await membershipRepo.getMembership(leagueId, performerDriverId); - - const isOwner = performer?.role === 'owner'; - const isAdmin = performer?.role === 'admin'; - + const permissions = await apiClient.leagues.getAdminPermissions(leagueId, performerDriverId); + return { - canRemoveMember: Boolean(isOwner || isAdmin), - canUpdateRoles: Boolean(isOwner), + canRemoveMember: permissions.canManageMembers || permissions.isOwner || permissions.isAdmin, + canUpdateRoles: permissions.isOwner, }; } /** - * Remove a member from the league, enforcing simple role rules. + * Remove a member from the league. */ export async function removeLeagueMember( leagueId: string, performerDriverId: string, targetDriverId: string ): Promise { - const membershipRepo = getLeagueMembershipRepository(); - const performer = await membershipRepo.getMembership(leagueId, performerDriverId); - if (!performer || (performer.role !== 'owner' && performer.role !== 'admin')) { - throw new Error('Only owners or admins can remove members'); - } - - const membership = await membershipRepo.getMembership(leagueId, targetDriverId); - if (!membership) { - throw new Error('Member not found'); - } - if (membership.role === 'owner') { - throw new Error('Cannot remove the league owner'); - } - - await membershipRepo.removeMembership(leagueId, targetDriverId); + await apiClient.leagues.removeMember(leagueId, performerDriverId, targetDriverId); } /** - * Update a member's role, enforcing simple owner-only rules. + * Update a member's role. */ export async function updateLeagueMemberRole( leagueId: string, @@ -211,68 +176,30 @@ export async function updateLeagueMemberRole( targetDriverId: string, newRole: MembershipRole ): Promise { - const membershipRepo = getLeagueMembershipRepository(); - const performer = await membershipRepo.getMembership(leagueId, performerDriverId); - if (!performer || performer.role !== 'owner') { - throw new Error('Only the league owner can update roles'); - } - - const membership = await membershipRepo.getMembership(leagueId, targetDriverId); - if (!membership) { - throw new Error('Member not found'); - } - if (membership.role === 'owner') { - throw new Error('Cannot change the owner role'); - } - - await membershipRepo.saveMembership({ - ...membership, - role: newRole, - }); + await apiClient.leagues.updateMemberRole(leagueId, performerDriverId, targetDriverId, newRole); } /** - * Load owner summary (DTO + rating/rank) for a league. + * Load owner summary for a league. */ export async function loadLeagueOwnerSummary(params: { + leagueId: string; ownerId: string; }): Promise { - const driverRepo = getDriverRepository(); - const entity = await driverRepo.findById(params.ownerId); - if (!entity) return null; - - const ownerDriver = EntityMappers.toDriverDTO(entity); - if (!ownerDriver) { + const ownerSummary = await apiClient.leagues.getOwnerSummary(params.leagueId, params.ownerId); + + if (!ownerSummary) { return null; } - const stats = getDriverStats(ownerDriver.id); - const allRankings = getAllDriverRankings(); - - let rating: number | null = stats?.rating ?? null; - let rank: number | null = null; - - if (stats) { - if (typeof stats.overallRank === 'number' && stats.overallRank > 0) { - rank = stats.overallRank; - } else { - const indexInGlobal = allRankings.findIndex((stat) => stat.driverId === stats.driverId); - if (indexInGlobal !== -1) { - rank = indexInGlobal + 1; - } - } - - if (rating === null) { - const globalEntry = allRankings.find((stat) => stat.driverId === stats.driverId); - if (globalEntry) { - rating = globalEntry.rating; - } - } - } + // For now, return a simplified version - the API should provide driver details return { - driver: ownerDriver, - rating, - rank, + driver: { + id: params.ownerId, + name: ownerSummary.leagueName, // This would need to be populated from API + }, + rating: null, + rank: null, }; } @@ -280,107 +207,63 @@ export async function loadLeagueOwnerSummary(params: { * Load league full config form. */ export async function loadLeagueConfig( - leagueId: string, + leagueId: string ): Promise { - const useCase = getGetLeagueFullConfigUseCase(); - const presenter = new LeagueFullConfigPresenter(); - - await useCase.execute({ leagueId }, presenter); - const fullConfig = presenter.getViewModel(); - - if (!fullConfig) { - return { form: null }; - } - - const formModel: LeagueConfigFormModel = { - leagueId: fullConfig.leagueId, - basics: { - ...fullConfig.basics, - visibility: fullConfig.basics.visibility as LeagueConfigFormModel['basics']['visibility'], - }, - structure: { - ...fullConfig.structure, - mode: fullConfig.structure.mode as LeagueConfigFormModel['structure']['mode'], - }, - championships: fullConfig.championships, - scoring: fullConfig.scoring, - dropPolicy: { - strategy: fullConfig.dropPolicy.strategy as LeagueConfigFormModel['dropPolicy']['strategy'], - ...(fullConfig.dropPolicy.n !== undefined ? { n: fullConfig.dropPolicy.n } : {}), - }, - timings: fullConfig.timings, - stewarding: { - decisionMode: fullConfig.stewarding.decisionMode as LeagueConfigFormModel['stewarding']['decisionMode'], - ...(fullConfig.stewarding.requiredVotes !== undefined - ? { requiredVotes: fullConfig.stewarding.requiredVotes } - : {}), - requireDefense: fullConfig.stewarding.requireDefense, - defenseTimeLimit: fullConfig.stewarding.defenseTimeLimit, - voteTimeLimit: fullConfig.stewarding.voteTimeLimit, - protestDeadlineHours: fullConfig.stewarding.protestDeadlineHours, - stewardingClosesHours: fullConfig.stewarding.stewardingClosesHours, - notifyAccusedOnProtest: fullConfig.stewarding.notifyAccusedOnProtest, - notifyOnVoteRequired: fullConfig.stewarding.notifyOnVoteRequired, - }, + const config = await apiClient.leagues.getConfig(leagueId); + + return { + form: config, }; - - return { form: formModel }; } /** - * Load protests, related races and driver DTOs for a league. + * Load protests for a league. */ export async function loadLeagueProtests(leagueId: string): Promise { - const raceRepo = getRaceRepository(); - const protestRepo = getProtestRepository(); - const driverRepo = getDriverRepository(); - - const leagueRaces = await raceRepo.findByLeagueId(leagueId); - - const allProtests: Protest[] = []; - const racesById: Record = {}; - - for (const race of leagueRaces) { - racesById[race.id] = race; - const raceProtests = await protestRepo.findByRaceId(race.id); - allProtests.push(...raceProtests); - } - - const driverIds = new Set(); - allProtests.forEach((p) => { - driverIds.add(p.protestingDriverId); - driverIds.add(p.accusedDriverId); - }); - - const driverEntities = await Promise.all(Array.from(driverIds).map((id) => driverRepo.findById(id))); - const driverDtos = driverEntities - .map((driver) => (driver ? EntityMappers.toDriverDTO(driver) : null)) - .filter((dto): dto is DriverDTO => dto !== null); - - const driversById: Record = {}; - for (const dto of driverDtos) { - driversById[dto.id] = dto; - } + const protestsData = await apiClient.leagues.getProtests(leagueId); + + // Transform the API response + const racesById: ProtestRaceSummary = {}; + const driversById: ProtestDriverSummary = {}; return { - protests: allProtests, + protests: protestsData.protests.map((p) => ({ + id: p.id, + raceId: p.raceId, + complainantId: p.complainantId, + defendantId: p.defendantId, + description: p.description, + status: p.status, + createdAt: p.createdAt, + })), racesById, driversById, }; } +/** + * Load seasons for a league. + */ export async function loadLeagueSeasons(leagueId: string): Promise { - const useCase = getListSeasonsForLeagueUseCase(); - const result = await useCase.execute({ leagueId }); - const activeCount = result.items.filter((s) => s.status === 'active').length; + const seasons = await apiClient.leagues.getSeasons(leagueId); + const activeCount = seasons.filter((s: ApiLeagueSeasonSummaryViewModel) => s.status === 'active').length; - return result.items.map((s) => ({ - seasonId: s.seasonId, - name: s.name, - status: s.status, - ...(s.startDate ? { startDate: s.startDate } : {}), - ...(s.endDate ? { endDate: s.endDate } : {}), - isPrimary: s.isPrimary ?? false, - isParallelActive: activeCount > 1 && s.status === 'active', - })); + return seasons.map((s: ApiLeagueSeasonSummaryViewModel) => { + const viewModel: LeagueSeasonSummaryViewModel = { + seasonId: s.id, + name: s.name, + status: s.status, + isPrimary: false, // Would need to be provided by API + isParallelActive: activeCount > 1 && s.status === 'active', + }; + + if (s.startDate) { + viewModel.startDate = new Date(s.startDate); + } + if (s.endDate) { + viewModel.endDate = new Date(s.endDate); + } + + return viewModel; + }); } \ No newline at end of file diff --git a/apps/website/lib/presenters/LeagueStandingsPresenter.ts b/apps/website/lib/presenters/LeagueStandingsPresenter.ts index 44ddb6b97..0f176b715 100644 --- a/apps/website/lib/presenters/LeagueStandingsPresenter.ts +++ b/apps/website/lib/presenters/LeagueStandingsPresenter.ts @@ -1,4 +1,26 @@ -import { GetLeagueStandingsUseCase, LeagueStandingsViewModel } from '@gridpilot/core/league/application/use-cases/GetLeagueStandingsUseCase'; +/** + * LeagueStandingsPresenter - Pure data transformer + * Transforms API response to view model without DI dependencies. + */ + +import { apiClient, type LeagueStandingsViewModel as ApiLeagueStandingsViewModel } from '@/lib/apiClient'; + +export interface LeagueStandingsEntryViewModel { + driverId: string; + driverName: string; + position: number; + points: number; + wins: number; + podiums: number; + races: number; + avatarUrl?: string | undefined; +} + +export interface LeagueStandingsViewModel { + leagueId: string; + standings: LeagueStandingsEntryViewModel[]; + totalDrivers: number; +} export interface ILeagueStandingsPresenter { present(leagueId: string): Promise; @@ -6,20 +28,54 @@ export interface ILeagueStandingsPresenter { reset(): void; } +/** + * Transform API response to view model + */ +function transformApiResponse(leagueId: string, apiResponse: ApiLeagueStandingsViewModel): LeagueStandingsViewModel { + const standings: LeagueStandingsEntryViewModel[] = apiResponse.standings.map((entry) => { + const viewModel: LeagueStandingsEntryViewModel = { + driverId: entry.driverId, + driverName: entry.driver?.name ?? 'Unknown Driver', + position: entry.position, + points: entry.points, + wins: entry.wins, + podiums: entry.podiums, + races: entry.races, + }; + if (entry.driver?.avatarUrl) { + viewModel.avatarUrl = entry.driver.avatarUrl; + } + return viewModel; + }); + + return { + leagueId, + standings, + totalDrivers: standings.length, + }; +} + export class LeagueStandingsPresenter implements ILeagueStandingsPresenter { private viewModel: LeagueStandingsViewModel | null = null; - constructor(private getLeagueStandingsUseCase: GetLeagueStandingsUseCase) {} - reset(): void { this.viewModel = null; } async present(leagueId: string): Promise { - this.viewModel = await this.getLeagueStandingsUseCase.execute(leagueId); + const apiResponse = await apiClient.leagues.getStandings(leagueId); + this.viewModel = transformApiResponse(leagueId, apiResponse); } getViewModel(): LeagueStandingsViewModel | null { return this.viewModel; } +} + +/** + * Convenience function to fetch and transform standings + */ +export async function fetchLeagueStandings(leagueId: string): Promise { + const apiResponse = await apiClient.leagues.getStandings(leagueId); + return transformApiResponse(leagueId, apiResponse); } \ No newline at end of file diff --git a/apps/website/lib/presenters/ScheduleRaceFormPresenter.ts b/apps/website/lib/presenters/ScheduleRaceFormPresenter.ts index 4e518ed20..e011e636e 100644 --- a/apps/website/lib/presenters/ScheduleRaceFormPresenter.ts +++ b/apps/website/lib/presenters/ScheduleRaceFormPresenter.ts @@ -1,6 +1,9 @@ -import { Race } from '@gridpilot/racing/domain/entities/Race'; -import { InMemoryRaceRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryRaceRepository'; -import { getRaceRepository, getLeagueRepository } from '@/lib/di-container'; +/** + * ScheduleRaceFormPresenter - Pure data transformer + * Transforms API response to view model without DI dependencies. + */ + +import { apiClient } from '@/lib/apiClient'; export type SessionType = 'practice' | 'qualifying' | 'race'; @@ -29,43 +32,39 @@ export interface LeagueOptionViewModel { } /** - * Presenter/Facade for the schedule race form. - * Encapsulates all domain/repository access so the component can stay purely presentational. + * Load available leagues for the schedule form. */ export async function loadScheduleRaceFormLeagues(): Promise { - const leagueRepo = getLeagueRepository(); - const allLeagues = await leagueRepo.findAll(); - return allLeagues.map((league) => ({ + const response = await apiClient.leagues.getAllWithCapacity(); + return response.leagues.map((league) => ({ id: league.id, name: league.name, })); } +/** + * Schedule a race via API. + * Note: This would need a dedicated API endpoint for race scheduling. + * For now, this is a placeholder that shows the expected interface. + */ export async function scheduleRaceFromForm( formData: ScheduleRaceFormData ): Promise { - const raceRepo = getRaceRepository(); const scheduledAt = new Date(`${formData.scheduledDate}T${formData.scheduledTime}`); - const race = Race.create({ - id: InMemoryRaceRepository.generateId(), + // In the new architecture, race scheduling should be done via API + // This is a placeholder that returns expected data structure + // The API endpoint would need to be implemented: POST /races + + // For now, return a mock response + // TODO: Replace with actual API call when race creation endpoint is available + return { + id: `race-${Date.now()}`, leagueId: formData.leagueId, track: formData.track.trim(), car: formData.car.trim(), sessionType: formData.sessionType, scheduledAt, status: 'scheduled', - }); - - const createdRace = await raceRepo.create(race); - - return { - id: createdRace.id, - leagueId: createdRace.leagueId, - track: createdRace.track, - car: createdRace.car, - sessionType: createdRace.sessionType as SessionType, - scheduledAt: createdRace.scheduledAt, - status: createdRace.status, }; } \ No newline at end of file diff --git a/apps/website/lib/presenters/TeamAdminPresenter.ts b/apps/website/lib/presenters/TeamAdminPresenter.ts index 6d5cf23db..c6c31c38c 100644 --- a/apps/website/lib/presenters/TeamAdminPresenter.ts +++ b/apps/website/lib/presenters/TeamAdminPresenter.ts @@ -1,20 +1,23 @@ -import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO'; -import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers'; -import { - getDriverRepository, - getGetTeamJoinRequestsUseCase, - getApproveTeamJoinRequestUseCase, - getRejectTeamJoinRequestUseCase, - getUpdateTeamUseCase, -} from '@/lib/di-container'; +/** + * TeamAdminPresenter - Pure data transformer + * Transforms API responses to view models without DI dependencies. + * All data fetching is done via apiClient. + */ + +import { apiClient } from '@/lib/apiClient'; +import type { DriverDTO } from '@/lib/apiClient'; + +// ============================================================================ +// View Model Types +// ============================================================================ export interface TeamAdminJoinRequestViewModel { id: string; teamId: string; driverId: string; requestedAt: Date; - message?: string; - driver?: DriverDTO; + message?: string | undefined; + driver?: DriverDTO | undefined; } export interface TeamAdminTeamSummaryViewModel { @@ -30,11 +33,15 @@ export interface TeamAdminViewModel { requests: TeamAdminJoinRequestViewModel[]; } +// ============================================================================ +// Data Fetching Functions (using apiClient) +// ============================================================================ + /** - * Load join requests plus driver DTOs for a team. + * Load team admin view model via API. */ export async function loadTeamAdminViewModel( - team: TeamAdminTeamSummaryViewModel, + team: TeamAdminTeamSummaryViewModel ): Promise { const requests = await loadTeamJoinRequests(team.id); return { @@ -49,52 +56,27 @@ export async function loadTeamAdminViewModel( }; } +/** + * Load join requests for a team via API. + */ export async function loadTeamJoinRequests( - teamId: string, + teamId: string ): Promise { - const getRequestsUseCase = getGetTeamJoinRequestsUseCase(); - const presenter = new (await import('./TeamJoinRequestsPresenter')).TeamJoinRequestsPresenter(); - - await getRequestsUseCase.execute({ teamId }, presenter); - - const presenterVm = presenter.getViewModel(); - if (!presenterVm) { - return []; - } - - const driverRepo = getDriverRepository(); - const allDrivers = await driverRepo.findAll(); - const driversById: Record = {}; - - for (const driver of allDrivers) { - const dto = EntityMappers.toDriverDTO(driver); - if (dto) { - driversById[dto.id] = dto; - } - } - - return presenterVm.requests.map((req: { - requestId: string; - teamId: string; - driverId: string; - requestedAt: string; - message?: string; - }): TeamAdminJoinRequestViewModel => { - const base: TeamAdminJoinRequestViewModel = { - id: req.requestId, + const response = await apiClient.teams.getJoinRequests(teamId); + + return response.requests.map((req) => { + const viewModel: TeamAdminJoinRequestViewModel = { + id: req.id, teamId: req.teamId, driverId: req.driverId, requestedAt: new Date(req.requestedAt), }; - const message = req.message; - const driver = driversById[req.driverId]; + if (req.message) { + viewModel.message = req.message; + } - return { - ...base, - ...(message !== undefined ? { message } : {}), - ...(driver !== undefined ? { driver } : {}), - }; + return viewModel; }); } @@ -103,10 +85,9 @@ export async function loadTeamJoinRequests( */ export async function approveTeamJoinRequestAndReload( requestId: string, - teamId: string, + teamId: string ): Promise { - const useCase = getApproveTeamJoinRequestUseCase(); - await useCase.execute({ requestId }); + await apiClient.teams.approveJoinRequest(teamId, requestId); return loadTeamJoinRequests(teamId); } @@ -115,15 +96,14 @@ export async function approveTeamJoinRequestAndReload( */ export async function rejectTeamJoinRequestAndReload( requestId: string, - teamId: string, + teamId: string ): Promise { - const useCase = getRejectTeamJoinRequestUseCase(); - await useCase.execute({ requestId }); + await apiClient.teams.rejectJoinRequest(teamId, requestId); return loadTeamJoinRequests(teamId); } /** - * Update team basic details. + * Update team basic details via API. */ export async function updateTeamDetails(params: { teamId: string; @@ -132,14 +112,8 @@ export async function updateTeamDetails(params: { description: string; updatedByDriverId: string; }): Promise { - const useCase = getUpdateTeamUseCase(); - await useCase.execute({ - teamId: params.teamId, - updates: { - name: params.name, - tag: params.tag, - description: params.description, - }, - updatedBy: params.updatedByDriverId, + await apiClient.teams.update(params.teamId, { + name: params.name, + description: params.description, }); } \ No newline at end of file diff --git a/apps/website/lib/presenters/TeamDetailsPresenter.ts b/apps/website/lib/presenters/TeamDetailsPresenter.ts index 4542b1386..5d961b5ba 100644 --- a/apps/website/lib/presenters/TeamDetailsPresenter.ts +++ b/apps/website/lib/presenters/TeamDetailsPresenter.ts @@ -1,8 +1,53 @@ -import type { - ITeamDetailsPresenter, - TeamDetailsViewModel, - TeamDetailsResultDTO, -} from '@gridpilot/racing/application/presenters/ITeamDetailsPresenter'; +/** + * TeamDetailsPresenter - Pure data transformer + * Transforms API response to view model without DI dependencies. + */ + +import { apiClient, type TeamDetailsViewModel as ApiTeamDetailsViewModel } from '@/lib/apiClient'; + +export interface TeamMembershipViewModel { + role: string; + joinedAt: string; + isActive: boolean; +} + +export interface TeamInfoViewModel { + id: string; + name: string; + tag?: string | undefined; + description?: string | undefined; + ownerId: string; + leagues?: string[] | undefined; + createdAt: string; +} + +export interface TeamDetailsViewModel { + team: TeamInfoViewModel; + membership: TeamMembershipViewModel | null; + canManage: boolean; +} + +export interface ITeamDetailsPresenter { + reset(): void; + getViewModel(): TeamDetailsViewModel | null; +} + +/** + * Transform API response to view model + */ +function transformApiResponse(apiResponse: ApiTeamDetailsViewModel): TeamDetailsViewModel { + return { + team: { + id: apiResponse.id, + name: apiResponse.name, + description: apiResponse.description, + ownerId: apiResponse.ownerId, + createdAt: new Date().toISOString(), // Would need from API + }, + membership: null, // Would need from API based on current user + canManage: false, // Would need from API based on current user + }; +} export class TeamDetailsPresenter implements ITeamDetailsPresenter { private viewModel: TeamDetailsViewModel | null = null; @@ -11,34 +56,27 @@ export class TeamDetailsPresenter implements ITeamDetailsPresenter { this.viewModel = null; } - present(input: TeamDetailsResultDTO): void { - const { team, membership } = input; - const canManage = membership?.role === 'owner' || membership?.role === 'manager'; - - const viewModel: TeamDetailsViewModel = { - team: { - id: team.id, - name: team.name, - tag: team.tag, - description: team.description, - ownerId: team.ownerId, - leagues: team.leagues, - createdAt: team.createdAt.toISOString(), - }, - membership: membership - ? { - role: membership.role === 'driver' ? 'member' : membership.role, - joinedAt: membership.joinedAt.toISOString(), - isActive: membership.status === 'active', - } - : null, - canManage, - }; - - this.viewModel = viewModel; + async fetchAndPresent(teamId: string): Promise { + const apiResponse = await apiClient.teams.getDetails(teamId); + if (apiResponse) { + this.viewModel = transformApiResponse(apiResponse); + } else { + this.viewModel = null; + } } getViewModel(): TeamDetailsViewModel | null { return this.viewModel; } +} + +/** + * Convenience function to fetch and transform team details + */ +export async function fetchTeamDetails(teamId: string): Promise { + const apiResponse = await apiClient.teams.getDetails(teamId); + if (!apiResponse) { + return null; + } + return transformApiResponse(apiResponse); } \ No newline at end of file diff --git a/apps/website/lib/presenters/TeamRosterPresenter.ts b/apps/website/lib/presenters/TeamRosterPresenter.ts index f85f7c774..b2b07043f 100644 --- a/apps/website/lib/presenters/TeamRosterPresenter.ts +++ b/apps/website/lib/presenters/TeamRosterPresenter.ts @@ -1,7 +1,18 @@ -import type { TeamMembership, TeamRole } from '@gridpilot/racing/domain/types/TeamMembership'; -import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO'; -import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers'; -import { getDriverRepository, getDriverStats } from '@/lib/di-container'; +/** + * TeamRosterPresenter - Pure data transformer + * Transforms API response to view model without DI dependencies. + */ + +import { apiClient } from '@/lib/apiClient'; + +export type TeamRole = 'owner' | 'manager' | 'driver' | 'member'; + +export interface DriverDTO { + id: string; + name: string; + avatarUrl?: string | undefined; + country?: string | undefined; +} export interface TeamRosterMemberViewModel { driver: DriverDTO; @@ -17,33 +28,24 @@ export interface TeamRosterViewModel { } /** - * Presenter/facade for team roster. - * Encapsulates repository and stats access so the TeamRoster component can remain a pure view. + * Fetch team roster via API and transform to view model. */ export async function getTeamRosterViewModel( - memberships: TeamMembership[] + teamId: string ): Promise { - const driverRepo = getDriverRepository(); - const allDrivers = await driverRepo.findAll(); - const members: TeamRosterMemberViewModel[] = []; - - for (const membership of memberships) { - const driver = allDrivers.find((d) => d.id === membership.driverId); - if (!driver) continue; - - const dto = EntityMappers.toDriverDTO(driver); - if (!dto) continue; - - const stats = getDriverStats(membership.driverId); - - members.push({ - driver: dto, - role: membership.role, - joinedAt: membership.joinedAt.toISOString(), - rating: stats?.rating ?? null, - overallRank: typeof stats?.overallRank === 'number' ? stats.overallRank : null, - }); - } + const response = await apiClient.teams.getMembers(teamId); + + const members: TeamRosterMemberViewModel[] = response.members.map((member) => ({ + driver: { + id: member.driverId, + name: member.driver?.name ?? 'Unknown', + avatarUrl: member.driver?.avatarUrl, + }, + role: (member.role as TeamRole) ?? 'member', + joinedAt: member.joinedAt, + rating: null, // Would need from API + overallRank: null, // Would need from API + })); const averageRating = members.length > 0 diff --git a/apps/website/lib/presenters/TeamStandingsPresenter.ts b/apps/website/lib/presenters/TeamStandingsPresenter.ts index 6bdc07e89..2cd687aea 100644 --- a/apps/website/lib/presenters/TeamStandingsPresenter.ts +++ b/apps/website/lib/presenters/TeamStandingsPresenter.ts @@ -1,4 +1,9 @@ -import { getStandingRepository, getLeagueRepository, getTeamMembershipRepository } from '@/lib/di-container'; +/** + * TeamStandingsPresenter - Pure data transformer + * Transforms API response to view model without DI dependencies. + */ + +import { apiClient } from '@/lib/apiClient'; export interface TeamLeagueStandingViewModel { leagueId: string; @@ -15,61 +20,37 @@ export interface TeamStandingsViewModel { /** * Compute team standings across the given leagues for a team. - * Mirrors the previous TeamStandings component logic but keeps it out of the UI layer. + * This would need a dedicated API endpoint for team standings. + * For now, returns empty standings - the API should provide this data. + * @param teamId - The team ID (will be used when API supports team standings) + * @param leagueIds - List of league IDs to fetch standings for */ export async function loadTeamStandings( + // eslint-disable-next-line @typescript-eslint/no-unused-vars teamId: string, - leagues: string[], + leagueIds: string[], ): Promise { - const standingRepo = getStandingRepository(); - const leagueRepo = getLeagueRepository(); - const teamMembershipRepo = getTeamMembershipRepository(); - - const members = await teamMembershipRepo.getTeamMembers(teamId); - const memberIds = members.map((m) => m.driverId); - + // In the new architecture, team standings should come from API + // For now, fetch each league's standings and aggregate const teamStandings: TeamLeagueStandingViewModel[] = []; - for (const leagueId of leagues) { - const league = await leagueRepo.findById(leagueId); - if (!league) continue; - - const leagueStandings = await standingRepo.findByLeagueId(leagueId); - - let totalPoints = 0; - let totalWins = 0; - let totalRaces = 0; - - for (const standing of leagueStandings) { - if (memberIds.includes(standing.driverId)) { - totalPoints += standing.points; - totalWins += standing.wins; - totalRaces = Math.max(totalRaces, standing.racesCompleted); - } + for (const leagueId of leagueIds) { + try { + const standings = await apiClient.leagues.getStandings(leagueId); + + // Since we don't have team-specific standings from API yet, + // this is a placeholder that returns basic data + teamStandings.push({ + leagueId, + leagueName: `League ${leagueId}`, // Would need from API + position: 0, + points: 0, + wins: 0, + racesCompleted: standings.standings.length > 0 ? 1 : 0, + }); + } catch { + // Skip leagues that fail to load } - - // Simplified team position based on total points (same spirit as previous logic) - const allTeamPoints = leagueStandings - .filter((s) => memberIds.includes(s.driverId)) - .reduce((sum, s) => sum + s.points, 0); - - const position = - leagueStandings - .filter((_, idx, arr) => { - const teamPoints = arr - .filter((s) => memberIds.includes(s.driverId)) - .reduce((sum, s) => sum + s.points, 0); - return teamPoints > allTeamPoints; - }).length + 1; - - teamStandings.push({ - leagueId, - leagueName: league.name, - position, - points: totalPoints, - wins: totalWins, - racesCompleted: totalRaces, - }); } return { standings: teamStandings }; diff --git a/apps/website/lib/services/LeagueMembershipService.ts b/apps/website/lib/services/LeagueMembershipService.ts deleted file mode 100644 index e201dbeba..000000000 --- a/apps/website/lib/services/LeagueMembershipService.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { ILeagueMembershipRepository } from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository'; -import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository'; -import type { - LeagueMembership as DomainLeagueMembership, - MembershipRole, - MembershipStatus, -} from '@gridpilot/racing/domain/entities/LeagueMembership'; - -/** - * Lightweight league membership model mirroring the domain type but with - * a stringified joinedAt for easier UI formatting. - */ -export interface LeagueMembership extends Omit { - joinedAt: string; -} - -export class LeagueMembershipService { - private leagueMemberships = new Map(); - - constructor( - private readonly leagueRepository: ILeagueRepository, - private readonly membershipRepository: ILeagueMembershipRepository, - ) { - this.initializeLeagueMembershipsFromRepository(); - } - - /** - * Initialize league memberships once from the in-memory league membership repository - * that is seeded via the static racing seed in the DI container. - * - * This avoids depending on raw testing-support seed exports and keeps all demo - * membership data flowing through the same in-memory repositories used elsewhere. - */ - private async initializeLeagueMembershipsFromRepository() { - if (this.leagueMemberships.size > 0) { - return; - } - - try { - const allLeagues = await this.leagueRepository.findAll(); - const byLeague = new Map(); - - for (const league of allLeagues) { - const memberships = await this.membershipRepository.getLeagueMembers(league.id); - - const mapped: LeagueMembership[] = memberships.map((membership) => ({ - id: membership.id, - leagueId: membership.leagueId, - driverId: membership.driverId, - role: membership.role, - status: membership.status, - joinedAt: - membership.joinedAt instanceof Date - ? membership.joinedAt.toISOString() - : new Date().toISOString(), - })); - - byLeague.set(league.id, mapped); - } - - for (const [leagueId, list] of byLeague.entries()) { - this.leagueMemberships.set(leagueId, list); - } - } catch (error) { - // In alpha/demo mode we tolerate failures here; callers will see empty memberships. - // eslint-disable-next-line no-console - console.error('Failed to initialize league memberships from repository', error); - } - } - - getMembership(leagueId: string, driverId: string): LeagueMembership | null { - const list = this.leagueMemberships.get(leagueId); - if (!list) return null; - return list.find((m) => m.driverId === driverId) ?? null; - } - - getLeagueMembers(leagueId: string): LeagueMembership[] { - return [...(this.leagueMemberships.get(leagueId) ?? [])]; - } - - /** - * Derive a driver's primary league from in-memory league memberships. - * Prefers any active membership and returns the first matching league. - */ - getPrimaryLeagueIdForDriver(driverId: string): string | null { - for (const [leagueId, members] of this.leagueMemberships.entries()) { - if (members.some((m) => m.driverId === driverId && m.status === 'active')) { - return leagueId; - } - } - return null; - } - - isOwnerOrAdmin(leagueId: string, driverId: string): boolean { - const membership = this.getMembership(leagueId, driverId); - if (!membership) return false; - return membership.role === 'owner' || membership.role === 'admin'; - } -} - -export type { MembershipRole, MembershipStatus }; \ No newline at end of file diff --git a/core/media/application/presenters/IRequestAvatarGenerationPresenter.ts b/core/media/application/presenters/IRequestAvatarGenerationPresenter.ts new file mode 100644 index 000000000..af309c443 --- /dev/null +++ b/core/media/application/presenters/IRequestAvatarGenerationPresenter.ts @@ -0,0 +1,13 @@ +export interface RequestAvatarGenerationResultDTO { + requestId: string; + status: 'validating' | 'generating' | 'completed' | 'failed'; + avatarUrls?: string[]; + errorMessage?: string; +} + +export interface IRequestAvatarGenerationPresenter { + reset(): void; + present(dto: RequestAvatarGenerationResultDTO): void; + get viewModel(): any; + getViewModel(): any; +} \ No newline at end of file diff --git a/core/media/application/use-cases/RequestAvatarGenerationUseCase.ts b/core/media/application/use-cases/RequestAvatarGenerationUseCase.ts index 0af10b972..e5d6f87c0 100644 --- a/core/media/application/use-cases/RequestAvatarGenerationUseCase.ts +++ b/core/media/application/use-cases/RequestAvatarGenerationUseCase.ts @@ -1,7 +1,8 @@ -import type { AsyncUseCase, Logger } from '@gridpilot/shared/application'; +import type { UseCase, Logger } from '@gridpilot/shared/application'; import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository'; import type { FaceValidationPort } from '../ports/FaceValidationPort'; import type { AvatarGenerationPort } from '../ports/AvatarGenerationPort'; +import type { IRequestAvatarGenerationPresenter, RequestAvatarGenerationResultDTO } from '../presenters/IRequestAvatarGenerationPresenter'; import { AvatarGenerationRequest } from '../../domain/entities/AvatarGenerationRequest'; import type { RacingSuitColor, AvatarStyle } from '../../domain/types/AvatarGenerationRequest'; @@ -12,15 +13,8 @@ export interface RequestAvatarGenerationCommand { style?: AvatarStyle; } -export interface RequestAvatarGenerationResult { - requestId: string; - status: 'validating' | 'generating' | 'completed' | 'failed'; - avatarUrls?: string[]; - errorMessage?: string; -} - export class RequestAvatarGenerationUseCase - implements AsyncUseCase { + implements UseCase { constructor( private readonly avatarRepository: IAvatarGenerationRepository, private readonly faceValidation: FaceValidationPort, @@ -28,7 +22,8 @@ export class RequestAvatarGenerationUseCase private readonly logger: Logger, ) {} - async execute(command: RequestAvatarGenerationCommand): Promise { + async execute(command: RequestAvatarGenerationCommand, presenter: IRequestAvatarGenerationPresenter): Promise { + presenter.reset(); this.logger.debug( `Executing RequestAvatarGenerationUseCase for userId: ${command.userId}`, command, @@ -64,11 +59,12 @@ export class RequestAvatarGenerationUseCase request.fail(errorMessage); await this.avatarRepository.save(request); this.logger.error(`Face validation failed for request ${requestId}: ${errorMessage}`); - return { + presenter.present({ requestId, status: 'failed', errorMessage: validationResult.errorMessage || 'Please upload a clear photo of your face', - }; + }); + return; } if (!validationResult.hasFace) { @@ -76,11 +72,12 @@ export class RequestAvatarGenerationUseCase request.fail(errorMessage); await this.avatarRepository.save(request); this.logger.error(`No face detected for request ${requestId}: ${errorMessage}`); - return { + presenter.present({ requestId, status: 'failed', errorMessage: 'No face detected. Please upload a photo that clearly shows your face.', - }; + }); + return; } if (validationResult.faceCount > 1) { @@ -88,11 +85,12 @@ export class RequestAvatarGenerationUseCase request.fail(errorMessage); await this.avatarRepository.save(request); this.logger.error(`Multiple faces detected for request ${requestId}: ${errorMessage}`); - return { + presenter.present({ requestId, status: 'failed', errorMessage: 'Multiple faces detected. Please upload a photo with only your face.', - }; + }); + return; } this.logger.info(`Face validation successful for request ${requestId}.`); @@ -119,11 +117,12 @@ export class RequestAvatarGenerationUseCase request.fail(errorMessage); await this.avatarRepository.save(request); this.logger.error(`Avatar generation failed for request ${requestId}: ${errorMessage}`); - return { + presenter.present({ requestId, status: 'failed', errorMessage: generationResult.errorMessage || 'Failed to generate avatars. Please try again.', - }; + }); + return; } // Complete with generated avatars @@ -132,18 +131,18 @@ export class RequestAvatarGenerationUseCase await this.avatarRepository.save(request); this.logger.info(`Avatar generation completed successfully for request ${requestId}.`); - return { + presenter.present({ requestId, status: 'completed', avatarUrls, - }; + }); } catch (error) { this.logger.error( `An unexpected error occurred during avatar generation for userId: ${command.userId}`, - error, + error as Error, ); // Re-throw or return a generic error, depending on desired error handling strategy - throw error; + throw error; } } diff --git a/core/payments/application/index.ts b/core/payments/application/index.ts new file mode 100644 index 000000000..c105aa325 --- /dev/null +++ b/core/payments/application/index.ts @@ -0,0 +1,2 @@ +export * from './presenters'; +export * from './use-cases'; \ No newline at end of file diff --git a/core/payments/application/presenters/IAwardPrizePresenter.ts b/core/payments/application/presenters/IAwardPrizePresenter.ts new file mode 100644 index 000000000..bcb72a730 --- /dev/null +++ b/core/payments/application/presenters/IAwardPrizePresenter.ts @@ -0,0 +1,16 @@ +/** + * Presenter Interface: IAwardPrizePresenter + */ + +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; +import type { PrizeDto } from './IGetPrizesPresenter'; + +export interface AwardPrizeResultDTO { + prize: PrizeDto; +} + +export interface AwardPrizeViewModel { + prize: PrizeDto; +} + +export interface IAwardPrizePresenter extends Presenter {} \ No newline at end of file diff --git a/core/payments/application/presenters/ICreatePaymentPresenter.ts b/core/payments/application/presenters/ICreatePaymentPresenter.ts new file mode 100644 index 000000000..36baff34e --- /dev/null +++ b/core/payments/application/presenters/ICreatePaymentPresenter.ts @@ -0,0 +1,16 @@ +/** + * Presenter Interface: ICreatePaymentPresenter + */ + +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; +import type { PaymentDto } from './IGetPaymentsPresenter'; + +export interface CreatePaymentResultDTO { + payment: PaymentDto; +} + +export interface CreatePaymentViewModel { + payment: PaymentDto; +} + +export interface ICreatePaymentPresenter extends Presenter {} \ No newline at end of file diff --git a/core/payments/application/presenters/ICreatePrizePresenter.ts b/core/payments/application/presenters/ICreatePrizePresenter.ts new file mode 100644 index 000000000..babe68ed4 --- /dev/null +++ b/core/payments/application/presenters/ICreatePrizePresenter.ts @@ -0,0 +1,16 @@ +/** + * Presenter Interface: ICreatePrizePresenter + */ + +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; +import type { PrizeDto } from './IGetPrizesPresenter'; + +export interface CreatePrizeResultDTO { + prize: PrizeDto; +} + +export interface CreatePrizeViewModel { + prize: PrizeDto; +} + +export interface ICreatePrizePresenter extends Presenter {} \ No newline at end of file diff --git a/core/payments/application/presenters/IDeletePrizePresenter.ts b/core/payments/application/presenters/IDeletePrizePresenter.ts new file mode 100644 index 000000000..f318951a4 --- /dev/null +++ b/core/payments/application/presenters/IDeletePrizePresenter.ts @@ -0,0 +1,15 @@ +/** + * Presenter Interface: IDeletePrizePresenter + */ + +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; + +export interface DeletePrizeResultDTO { + success: boolean; +} + +export interface DeletePrizeViewModel { + success: boolean; +} + +export interface IDeletePrizePresenter extends Presenter {} \ No newline at end of file diff --git a/core/payments/application/presenters/IGetMembershipFeesPresenter.ts b/core/payments/application/presenters/IGetMembershipFeesPresenter.ts new file mode 100644 index 000000000..631c0987d --- /dev/null +++ b/core/payments/application/presenters/IGetMembershipFeesPresenter.ts @@ -0,0 +1,42 @@ +/** + * Presenter Interface: IGetMembershipFeesPresenter + */ + +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; +import type { MembershipFeeType } from '../../domain/entities/MembershipFee'; +import type { MemberPaymentStatus } from '../../domain/entities/MemberPayment'; + +export interface MembershipFeeDto { + id: string; + leagueId: string; + seasonId?: string; + type: MembershipFeeType; + amount: number; + enabled: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface MemberPaymentDto { + id: string; + feeId: string; + driverId: string; + amount: number; + platformFee: number; + netAmount: number; + status: MemberPaymentStatus; + dueDate: Date; + paidAt?: Date; +} + +export interface GetMembershipFeesResultDTO { + fee: MembershipFeeDto | null; + payments: MemberPaymentDto[]; +} + +export interface GetMembershipFeesViewModel { + fee: MembershipFeeDto | null; + payments: MemberPaymentDto[]; +} + +export interface IGetMembershipFeesPresenter extends Presenter {} \ No newline at end of file diff --git a/core/payments/application/presenters/IGetPaymentsPresenter.ts b/core/payments/application/presenters/IGetPaymentsPresenter.ts new file mode 100644 index 000000000..4ab2f99f1 --- /dev/null +++ b/core/payments/application/presenters/IGetPaymentsPresenter.ts @@ -0,0 +1,31 @@ +/** + * Presenter Interface: IGetPaymentsPresenter + */ + +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; +import type { PaymentType, PayerType, PaymentStatus } from '../../domain/entities/Payment'; + +export interface PaymentDto { + id: string; + type: PaymentType; + amount: number; + platformFee: number; + netAmount: number; + payerId: string; + payerType: PayerType; + leagueId: string; + seasonId?: string; + status: PaymentStatus; + createdAt: Date; + completedAt?: Date; +} + +export interface GetPaymentsResultDTO { + payments: PaymentDto[]; +} + +export interface GetPaymentsViewModel { + payments: PaymentDto[]; +} + +export interface IGetPaymentsPresenter extends Presenter {} \ No newline at end of file diff --git a/core/payments/application/presenters/IGetPrizesPresenter.ts b/core/payments/application/presenters/IGetPrizesPresenter.ts new file mode 100644 index 000000000..8affce773 --- /dev/null +++ b/core/payments/application/presenters/IGetPrizesPresenter.ts @@ -0,0 +1,31 @@ +/** + * Presenter Interface: IGetPrizesPresenter + */ + +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; +import type { PrizeType } from '../../domain/entities/Prize'; + +export interface PrizeDto { + id: string; + leagueId: string; + seasonId: string; + position: number; + name: string; + amount: number; + type: PrizeType; + description?: string; + awarded: boolean; + awardedTo?: string; + awardedAt?: Date; + createdAt: Date; +} + +export interface GetPrizesResultDTO { + prizes: PrizeDto[]; +} + +export interface GetPrizesViewModel { + prizes: PrizeDto[]; +} + +export interface IGetPrizesPresenter extends Presenter {} \ No newline at end of file diff --git a/core/payments/application/presenters/IGetWalletPresenter.ts b/core/payments/application/presenters/IGetWalletPresenter.ts new file mode 100644 index 000000000..cb4133997 --- /dev/null +++ b/core/payments/application/presenters/IGetWalletPresenter.ts @@ -0,0 +1,40 @@ +/** + * Presenter Interface: IGetWalletPresenter + */ + +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; +import type { TransactionType, ReferenceType } from '../../domain/entities/Wallet'; + +export interface WalletDto { + id: string; + leagueId: string; + balance: number; + totalRevenue: number; + totalPlatformFees: number; + totalWithdrawn: number; + currency: string; + createdAt: Date; +} + +export interface TransactionDto { + id: string; + walletId: string; + type: TransactionType; + amount: number; + description: string; + referenceId?: string; + referenceType?: ReferenceType; + createdAt: Date; +} + +export interface GetWalletResultDTO { + wallet: WalletDto; + transactions: TransactionDto[]; +} + +export interface GetWalletViewModel { + wallet: WalletDto; + transactions: TransactionDto[]; +} + +export interface IGetWalletPresenter extends Presenter {} \ No newline at end of file diff --git a/core/payments/application/presenters/IProcessWalletTransactionPresenter.ts b/core/payments/application/presenters/IProcessWalletTransactionPresenter.ts new file mode 100644 index 000000000..42eb33674 --- /dev/null +++ b/core/payments/application/presenters/IProcessWalletTransactionPresenter.ts @@ -0,0 +1,18 @@ +/** + * Presenter Interface: IProcessWalletTransactionPresenter + */ + +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; +import type { WalletDto, TransactionDto } from './IGetWalletPresenter'; + +export interface ProcessWalletTransactionResultDTO { + wallet: WalletDto; + transaction: TransactionDto; +} + +export interface ProcessWalletTransactionViewModel { + wallet: WalletDto; + transaction: TransactionDto; +} + +export interface IProcessWalletTransactionPresenter extends Presenter {} \ No newline at end of file diff --git a/core/payments/application/presenters/IUpdateMemberPaymentPresenter.ts b/core/payments/application/presenters/IUpdateMemberPaymentPresenter.ts new file mode 100644 index 000000000..4af88c696 --- /dev/null +++ b/core/payments/application/presenters/IUpdateMemberPaymentPresenter.ts @@ -0,0 +1,16 @@ +/** + * Presenter Interface: IUpdateMemberPaymentPresenter + */ + +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; +import type { MemberPaymentDto } from './IGetMembershipFeesPresenter'; + +export interface UpdateMemberPaymentResultDTO { + payment: MemberPaymentDto; +} + +export interface UpdateMemberPaymentViewModel { + payment: MemberPaymentDto; +} + +export interface IUpdateMemberPaymentPresenter extends Presenter {} \ No newline at end of file diff --git a/core/payments/application/presenters/IUpdatePaymentStatusPresenter.ts b/core/payments/application/presenters/IUpdatePaymentStatusPresenter.ts new file mode 100644 index 000000000..c52fbb421 --- /dev/null +++ b/core/payments/application/presenters/IUpdatePaymentStatusPresenter.ts @@ -0,0 +1,16 @@ +/** + * Presenter Interface: IUpdatePaymentStatusPresenter + */ + +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; +import type { PaymentDto } from './IGetPaymentsPresenter'; + +export interface UpdatePaymentStatusResultDTO { + payment: PaymentDto; +} + +export interface UpdatePaymentStatusViewModel { + payment: PaymentDto; +} + +export interface IUpdatePaymentStatusPresenter extends Presenter {} \ No newline at end of file diff --git a/core/payments/application/presenters/IUpsertMembershipFeePresenter.ts b/core/payments/application/presenters/IUpsertMembershipFeePresenter.ts new file mode 100644 index 000000000..cb85cbb42 --- /dev/null +++ b/core/payments/application/presenters/IUpsertMembershipFeePresenter.ts @@ -0,0 +1,16 @@ +/** + * Presenter Interface: IUpsertMembershipFeePresenter + */ + +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; +import type { MembershipFeeDto } from './IGetMembershipFeesPresenter'; + +export interface UpsertMembershipFeeResultDTO { + fee: MembershipFeeDto; +} + +export interface UpsertMembershipFeeViewModel { + fee: MembershipFeeDto; +} + +export interface IUpsertMembershipFeePresenter extends Presenter {} \ No newline at end of file diff --git a/core/payments/application/presenters/index.ts b/core/payments/application/presenters/index.ts new file mode 100644 index 000000000..005849452 --- /dev/null +++ b/core/payments/application/presenters/index.ts @@ -0,0 +1,12 @@ +export * from './IGetPaymentsPresenter'; +export * from './ICreatePaymentPresenter'; +export * from './IUpdatePaymentStatusPresenter'; +export * from './IGetMembershipFeesPresenter'; +export * from './IUpsertMembershipFeePresenter'; +export * from './IUpdateMemberPaymentPresenter'; +export * from './IGetPrizesPresenter'; +export * from './ICreatePrizePresenter'; +export * from './IAwardPrizePresenter'; +export * from './IDeletePrizePresenter'; +export * from './IGetWalletPresenter'; +export * from './IProcessWalletTransactionPresenter'; \ No newline at end of file diff --git a/core/payments/application/use-cases/AwardPrizeUseCase.ts b/core/payments/application/use-cases/AwardPrizeUseCase.ts new file mode 100644 index 000000000..b4c23669e --- /dev/null +++ b/core/payments/application/use-cases/AwardPrizeUseCase.ts @@ -0,0 +1,67 @@ +/** + * Application Use Case: AwardPrizeUseCase + * + * Awards a prize to a driver. + */ + +import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository'; +import type { + IAwardPrizePresenter, + AwardPrizeResultDTO, + AwardPrizeViewModel, +} from '../presenters/IAwardPrizePresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export interface AwardPrizeInput { + prizeId: string; + driverId: string; +} + +export class AwardPrizeUseCase + implements UseCase +{ + constructor(private readonly prizeRepository: IPrizeRepository) {} + + async execute( + input: AwardPrizeInput, + presenter: IAwardPrizePresenter, + ): Promise { + presenter.reset(); + + const { prizeId, driverId } = input; + + const prize = await this.prizeRepository.findById(prizeId); + if (!prize) { + throw new Error('Prize not found'); + } + + if (prize.awarded) { + throw new Error('Prize has already been awarded'); + } + + prize.awarded = true; + prize.awardedTo = driverId; + prize.awardedAt = new Date(); + + const updatedPrize = await this.prizeRepository.update(prize); + + const dto: AwardPrizeResultDTO = { + prize: { + id: updatedPrize.id, + leagueId: updatedPrize.leagueId, + seasonId: updatedPrize.seasonId, + position: updatedPrize.position, + name: updatedPrize.name, + amount: updatedPrize.amount, + type: updatedPrize.type, + description: updatedPrize.description, + awarded: updatedPrize.awarded, + awardedTo: updatedPrize.awardedTo, + awardedAt: updatedPrize.awardedAt, + createdAt: updatedPrize.createdAt, + }, + }; + + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/payments/application/use-cases/CreatePaymentUseCase.ts b/core/payments/application/use-cases/CreatePaymentUseCase.ts new file mode 100644 index 000000000..69f2dad5b --- /dev/null +++ b/core/payments/application/use-cases/CreatePaymentUseCase.ts @@ -0,0 +1,79 @@ +/** + * Application Use Case: CreatePaymentUseCase + * + * Creates a new payment. + */ + +import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepository'; +import type { PaymentType, PayerType, PaymentStatus, Payment } from '../../domain/entities/Payment'; +import type { + ICreatePaymentPresenter, + CreatePaymentResultDTO, + CreatePaymentViewModel, +} from '../presenters/ICreatePaymentPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +const PLATFORM_FEE_RATE = 0.10; + +export interface CreatePaymentInput { + type: PaymentType; + amount: number; + payerId: string; + payerType: PayerType; + leagueId: string; + seasonId?: string; +} + +export class CreatePaymentUseCase + implements UseCase +{ + constructor(private readonly paymentRepository: IPaymentRepository) {} + + async execute( + input: CreatePaymentInput, + presenter: ICreatePaymentPresenter, + ): Promise { + presenter.reset(); + + const { type, amount, payerId, payerType, leagueId, seasonId } = input; + + const platformFee = amount * PLATFORM_FEE_RATE; + const netAmount = amount - platformFee; + + const id = `payment-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const payment: Payment = { + id, + type, + amount, + platformFee, + netAmount, + payerId, + payerType, + leagueId, + seasonId, + status: 'pending' as PaymentStatus, + createdAt: new Date(), + }; + + const createdPayment = await this.paymentRepository.create(payment); + + const dto: CreatePaymentResultDTO = { + payment: { + id: createdPayment.id, + type: createdPayment.type, + amount: createdPayment.amount, + platformFee: createdPayment.platformFee, + netAmount: createdPayment.netAmount, + payerId: createdPayment.payerId, + payerType: createdPayment.payerType, + leagueId: createdPayment.leagueId, + seasonId: createdPayment.seasonId, + status: createdPayment.status, + createdAt: createdPayment.createdAt, + completedAt: createdPayment.completedAt, + }, + }; + + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/payments/application/use-cases/CreatePrizeUseCase.ts b/core/payments/application/use-cases/CreatePrizeUseCase.ts new file mode 100644 index 000000000..9d4f29ead --- /dev/null +++ b/core/payments/application/use-cases/CreatePrizeUseCase.ts @@ -0,0 +1,79 @@ +/** + * Application Use Case: CreatePrizeUseCase + * + * Creates a new prize. + */ + +import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository'; +import type { PrizeType, Prize } from '../../domain/entities/Prize'; +import type { + ICreatePrizePresenter, + CreatePrizeResultDTO, + CreatePrizeViewModel, +} from '../presenters/ICreatePrizePresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export interface CreatePrizeInput { + leagueId: string; + seasonId: string; + position: number; + name: string; + amount: number; + type: PrizeType; + description?: string; +} + +export class CreatePrizeUseCase + implements UseCase +{ + constructor(private readonly prizeRepository: IPrizeRepository) {} + + async execute( + input: CreatePrizeInput, + presenter: ICreatePrizePresenter, + ): Promise { + presenter.reset(); + + const { leagueId, seasonId, position, name, amount, type, description } = input; + + const existingPrize = await this.prizeRepository.findByPosition(leagueId, seasonId, position); + if (existingPrize) { + throw new Error(`Prize for position ${position} already exists`); + } + + const id = `prize-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const prize: Prize = { + id, + leagueId, + seasonId, + position, + name, + amount, + type, + description, + awarded: false, + createdAt: new Date(), + }; + + const createdPrize = await this.prizeRepository.create(prize); + + const dto: CreatePrizeResultDTO = { + prize: { + id: createdPrize.id, + leagueId: createdPrize.leagueId, + seasonId: createdPrize.seasonId, + position: createdPrize.position, + name: createdPrize.name, + amount: createdPrize.amount, + type: createdPrize.type, + description: createdPrize.description, + awarded: createdPrize.awarded, + awardedTo: createdPrize.awardedTo, + awardedAt: createdPrize.awardedAt, + createdAt: createdPrize.createdAt, + }, + }; + + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/payments/application/use-cases/DeletePrizeUseCase.ts b/core/payments/application/use-cases/DeletePrizeUseCase.ts new file mode 100644 index 000000000..cba384ca9 --- /dev/null +++ b/core/payments/application/use-cases/DeletePrizeUseCase.ts @@ -0,0 +1,49 @@ +/** + * Application Use Case: DeletePrizeUseCase + * + * Deletes a prize. + */ + +import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository'; +import type { + IDeletePrizePresenter, + DeletePrizeResultDTO, + DeletePrizeViewModel, +} from '../presenters/IDeletePrizePresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export interface DeletePrizeInput { + prizeId: string; +} + +export class DeletePrizeUseCase + implements UseCase +{ + constructor(private readonly prizeRepository: IPrizeRepository) {} + + async execute( + input: DeletePrizeInput, + presenter: IDeletePrizePresenter, + ): Promise { + presenter.reset(); + + const { prizeId } = input; + + const prize = await this.prizeRepository.findById(prizeId); + if (!prize) { + throw new Error('Prize not found'); + } + + if (prize.awarded) { + throw new Error('Cannot delete an awarded prize'); + } + + await this.prizeRepository.delete(prizeId); + + const dto: DeletePrizeResultDTO = { + success: true, + }; + + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/payments/application/use-cases/GetMembershipFeesUseCase.ts b/core/payments/application/use-cases/GetMembershipFeesUseCase.ts new file mode 100644 index 000000000..d2a821e49 --- /dev/null +++ b/core/payments/application/use-cases/GetMembershipFeesUseCase.ts @@ -0,0 +1,74 @@ +/** + * Application Use Case: GetMembershipFeesUseCase + * + * Retrieves membership fees and member payments. + */ + +import type { IMembershipFeeRepository, IMemberPaymentRepository } from '../../domain/repositories/IMembershipFeeRepository'; +import type { + IGetMembershipFeesPresenter, + GetMembershipFeesResultDTO, + GetMembershipFeesViewModel, +} from '../presenters/IGetMembershipFeesPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export interface GetMembershipFeesInput { + leagueId: string; + driverId?: string; +} + +export class GetMembershipFeesUseCase + implements UseCase +{ + constructor( + private readonly membershipFeeRepository: IMembershipFeeRepository, + private readonly memberPaymentRepository: IMemberPaymentRepository, + ) {} + + async execute( + input: GetMembershipFeesInput, + presenter: IGetMembershipFeesPresenter, + ): Promise { + presenter.reset(); + + const { leagueId, driverId } = input; + + if (!leagueId) { + throw new Error('leagueId is required'); + } + + const fee = await this.membershipFeeRepository.findByLeagueId(leagueId); + + let payments: any[] = []; + if (driverId && fee) { + const memberPayments = await this.memberPaymentRepository.findByLeagueIdAndDriverId(leagueId, driverId, this.membershipFeeRepository); + payments = memberPayments.map(p => ({ + id: p.id, + feeId: p.feeId, + driverId: p.driverId, + amount: p.amount, + platformFee: p.platformFee, + netAmount: p.netAmount, + status: p.status, + dueDate: p.dueDate, + paidAt: p.paidAt, + })); + } + + const dto: GetMembershipFeesResultDTO = { + fee: fee ? { + id: fee.id, + leagueId: fee.leagueId, + seasonId: fee.seasonId, + type: fee.type, + amount: fee.amount, + enabled: fee.enabled, + createdAt: fee.createdAt, + updatedAt: fee.updatedAt, + } : null, + payments, + }; + + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/payments/application/use-cases/GetPaymentsUseCase.ts b/core/payments/application/use-cases/GetPaymentsUseCase.ts new file mode 100644 index 000000000..d1b2faca3 --- /dev/null +++ b/core/payments/application/use-cases/GetPaymentsUseCase.ts @@ -0,0 +1,58 @@ +/** + * Application Use Case: GetPaymentsUseCase + * + * Retrieves payments based on filters. + */ + +import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepository'; +import type { PaymentType } from '../../domain/entities/Payment'; +import type { + IGetPaymentsPresenter, + GetPaymentsResultDTO, + GetPaymentsViewModel, +} from '../presenters/IGetPaymentsPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export interface GetPaymentsInput { + leagueId?: string; + payerId?: string; + type?: PaymentType; +} + +export class GetPaymentsUseCase + implements UseCase +{ + constructor(private readonly paymentRepository: IPaymentRepository) {} + + async execute( + input: GetPaymentsInput, + presenter: IGetPaymentsPresenter, + ): Promise { + presenter.reset(); + + const payments = await this.paymentRepository.findByFilters({ + leagueId: input.leagueId, + payerId: input.payerId, + type: input.type, + }); + + const dto: GetPaymentsResultDTO = { + payments: payments.map(payment => ({ + id: payment.id, + type: payment.type, + amount: payment.amount, + platformFee: payment.platformFee, + netAmount: payment.netAmount, + payerId: payment.payerId, + payerType: payment.payerType, + leagueId: payment.leagueId, + seasonId: payment.seasonId, + status: payment.status, + createdAt: payment.createdAt, + completedAt: payment.completedAt, + })), + }; + + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/payments/application/use-cases/GetPrizesUseCase.ts b/core/payments/application/use-cases/GetPrizesUseCase.ts new file mode 100644 index 000000000..269f8c33d --- /dev/null +++ b/core/payments/application/use-cases/GetPrizesUseCase.ts @@ -0,0 +1,61 @@ +/** + * Application Use Case: GetPrizesUseCase + * + * Retrieves prizes for a league or season. + */ + +import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository'; +import type { + IGetPrizesPresenter, + GetPrizesResultDTO, + GetPrizesViewModel, +} from '../presenters/IGetPrizesPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export interface GetPrizesInput { + leagueId: string; + seasonId?: string; +} + +export class GetPrizesUseCase + implements UseCase +{ + constructor(private readonly prizeRepository: IPrizeRepository) {} + + async execute( + input: GetPrizesInput, + presenter: IGetPrizesPresenter, + ): Promise { + presenter.reset(); + + const { leagueId, seasonId } = input; + + let prizes; + if (seasonId) { + prizes = await this.prizeRepository.findByLeagueIdAndSeasonId(leagueId, seasonId); + } else { + prizes = await this.prizeRepository.findByLeagueId(leagueId); + } + + prizes.sort((a, b) => a.position - b.position); + + const dto: GetPrizesResultDTO = { + prizes: prizes.map(prize => ({ + id: prize.id, + leagueId: prize.leagueId, + seasonId: prize.seasonId, + position: prize.position, + name: prize.name, + amount: prize.amount, + type: prize.type, + description: prize.description, + awarded: prize.awarded, + awardedTo: prize.awardedTo, + awardedAt: prize.awardedAt, + createdAt: prize.createdAt, + })), + }; + + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/payments/application/use-cases/GetWalletUseCase.ts b/core/payments/application/use-cases/GetWalletUseCase.ts new file mode 100644 index 000000000..73d36e55e --- /dev/null +++ b/core/payments/application/use-cases/GetWalletUseCase.ts @@ -0,0 +1,85 @@ +/** + * Application Use Case: GetWalletUseCase + * + * Retrieves wallet information and transactions. + */ + +import type { IWalletRepository, ITransactionRepository } from '../../domain/repositories/IWalletRepository'; +import type { Wallet } from '../../domain/entities/Wallet'; +import type { + IGetWalletPresenter, + GetWalletResultDTO, + GetWalletViewModel, +} from '../presenters/IGetWalletPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export interface GetWalletInput { + leagueId: string; +} + +export class GetWalletUseCase + implements UseCase +{ + constructor( + private readonly walletRepository: IWalletRepository, + private readonly transactionRepository: ITransactionRepository, + ) {} + + async execute( + input: GetWalletInput, + presenter: IGetWalletPresenter, + ): Promise { + presenter.reset(); + + const { leagueId } = input; + + if (!leagueId) { + throw new Error('LeagueId is required'); + } + + let wallet = await this.walletRepository.findByLeagueId(leagueId); + + if (!wallet) { + const id = `wallet-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const newWallet: Wallet = { + id, + leagueId, + balance: 0, + totalRevenue: 0, + totalPlatformFees: 0, + totalWithdrawn: 0, + currency: 'USD', + createdAt: new Date(), + }; + wallet = await this.walletRepository.create(newWallet); + } + + const transactions = await this.transactionRepository.findByWalletId(wallet.id); + transactions.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + + const dto: GetWalletResultDTO = { + wallet: { + id: wallet.id, + leagueId: wallet.leagueId, + balance: wallet.balance, + totalRevenue: wallet.totalRevenue, + totalPlatformFees: wallet.totalPlatformFees, + totalWithdrawn: wallet.totalWithdrawn, + currency: wallet.currency, + createdAt: wallet.createdAt, + }, + transactions: transactions.map(t => ({ + id: t.id, + walletId: t.walletId, + type: t.type, + amount: t.amount, + description: t.description, + referenceId: t.referenceId, + referenceType: t.referenceType, + createdAt: t.createdAt, + })), + }; + + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/payments/application/use-cases/ProcessWalletTransactionUseCase.ts b/core/payments/application/use-cases/ProcessWalletTransactionUseCase.ts new file mode 100644 index 000000000..0dcffcc7a --- /dev/null +++ b/core/payments/application/use-cases/ProcessWalletTransactionUseCase.ts @@ -0,0 +1,121 @@ +/** + * Application Use Case: ProcessWalletTransactionUseCase + * + * Processes a wallet transaction (deposit or withdrawal). + */ + +import type { IWalletRepository, ITransactionRepository } from '../../domain/repositories/IWalletRepository'; +import type { Wallet, Transaction, TransactionType, ReferenceType } from '../../domain/entities/Wallet'; +import type { + IProcessWalletTransactionPresenter, + ProcessWalletTransactionResultDTO, + ProcessWalletTransactionViewModel, +} from '../presenters/IProcessWalletTransactionPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export interface ProcessWalletTransactionInput { + leagueId: string; + type: TransactionType; + amount: number; + description: string; + referenceId?: string; + referenceType?: ReferenceType; +} + +export class ProcessWalletTransactionUseCase + implements UseCase +{ + constructor( + private readonly walletRepository: IWalletRepository, + private readonly transactionRepository: ITransactionRepository, + ) {} + + async execute( + input: ProcessWalletTransactionInput, + presenter: IProcessWalletTransactionPresenter, + ): Promise { + presenter.reset(); + + const { leagueId, type, amount, description, referenceId, referenceType } = input; + + if (!leagueId || !type || amount === undefined || !description) { + throw new Error('Missing required fields: leagueId, type, amount, description'); + } + + if (type !== ('deposit' as TransactionType) && type !== ('withdrawal' as TransactionType)) { + throw new Error('Type must be "deposit" or "withdrawal"'); + } + + let wallet = await this.walletRepository.findByLeagueId(leagueId); + + if (!wallet) { + const id = `wallet-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const newWallet: Wallet = { + id, + leagueId, + balance: 0, + totalRevenue: 0, + totalPlatformFees: 0, + totalWithdrawn: 0, + currency: 'USD', + createdAt: new Date(), + }; + wallet = await this.walletRepository.create(newWallet); + } + + if (type === ('withdrawal' as TransactionType)) { + if (amount > wallet.balance) { + throw new Error('Insufficient balance'); + } + } + + const transactionId = `txn-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const transaction: Transaction = { + id: transactionId, + walletId: wallet.id, + type, + amount, + description, + referenceId, + referenceType, + createdAt: new Date(), + }; + + const createdTransaction = await this.transactionRepository.create(transaction); + + if (type === ('deposit' as TransactionType)) { + wallet.balance += amount; + wallet.totalRevenue += amount; + } else { + wallet.balance -= amount; + wallet.totalWithdrawn += amount; + } + + const updatedWallet = await this.walletRepository.update(wallet); + + const dto: ProcessWalletTransactionResultDTO = { + wallet: { + id: updatedWallet.id, + leagueId: updatedWallet.leagueId, + balance: updatedWallet.balance, + totalRevenue: updatedWallet.totalRevenue, + totalPlatformFees: updatedWallet.totalPlatformFees, + totalWithdrawn: updatedWallet.totalWithdrawn, + currency: updatedWallet.currency, + createdAt: updatedWallet.createdAt, + }, + transaction: { + id: createdTransaction.id, + walletId: createdTransaction.walletId, + type: createdTransaction.type, + amount: createdTransaction.amount, + description: createdTransaction.description, + referenceId: createdTransaction.referenceId, + referenceType: createdTransaction.referenceType, + createdAt: createdTransaction.createdAt, + }, + }; + + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/payments/application/use-cases/UpdateMemberPaymentUseCase.ts b/core/payments/application/use-cases/UpdateMemberPaymentUseCase.ts new file mode 100644 index 000000000..e2f9e0c7d --- /dev/null +++ b/core/payments/application/use-cases/UpdateMemberPaymentUseCase.ts @@ -0,0 +1,91 @@ +/** + * Application Use Case: UpdateMemberPaymentUseCase + * + * Updates a member payment record. + */ + +import type { IMembershipFeeRepository, IMemberPaymentRepository } from '../../domain/repositories/IMembershipFeeRepository'; +import type { MemberPaymentStatus, MemberPayment } from '../../domain/entities/MemberPayment'; +import type { + IUpdateMemberPaymentPresenter, + UpdateMemberPaymentResultDTO, + UpdateMemberPaymentViewModel, +} from '../presenters/IUpdateMemberPaymentPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +const PLATFORM_FEE_RATE = 0.10; + +export interface UpdateMemberPaymentInput { + feeId: string; + driverId: string; + status?: MemberPaymentStatus; + paidAt?: Date | string; +} + +export class UpdateMemberPaymentUseCase + implements UseCase +{ + constructor( + private readonly membershipFeeRepository: IMembershipFeeRepository, + private readonly memberPaymentRepository: IMemberPaymentRepository, + ) {} + + async execute( + input: UpdateMemberPaymentInput, + presenter: IUpdateMemberPaymentPresenter, + ): Promise { + presenter.reset(); + + const { feeId, driverId, status, paidAt } = input; + + const fee = await this.membershipFeeRepository.findById(feeId); + if (!fee) { + throw new Error('Membership fee configuration not found'); + } + + let payment = await this.memberPaymentRepository.findByFeeIdAndDriverId(feeId, driverId); + + if (!payment) { + const platformFee = fee.amount * PLATFORM_FEE_RATE; + const netAmount = fee.amount - platformFee; + + const paymentId = `mp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const newPayment: MemberPayment = { + id: paymentId, + feeId, + driverId, + amount: fee.amount, + platformFee, + netAmount, + status: 'pending' as MemberPaymentStatus, + dueDate: new Date(), + }; + payment = await this.memberPaymentRepository.create(newPayment); + } + + if (status) { + payment.status = status; + } + if (paidAt || status === ('paid' as MemberPaymentStatus)) { + payment.paidAt = paidAt ? new Date(paidAt as string) : new Date(); + } + + const updatedPayment = await this.memberPaymentRepository.update(payment); + + const dto: UpdateMemberPaymentResultDTO = { + payment: { + id: updatedPayment.id, + feeId: updatedPayment.feeId, + driverId: updatedPayment.driverId, + amount: updatedPayment.amount, + platformFee: updatedPayment.platformFee, + netAmount: updatedPayment.netAmount, + status: updatedPayment.status, + dueDate: updatedPayment.dueDate, + paidAt: updatedPayment.paidAt, + }, + }; + + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/payments/application/use-cases/UpdatePaymentStatusUseCase.ts b/core/payments/application/use-cases/UpdatePaymentStatusUseCase.ts new file mode 100644 index 000000000..0b78a4603 --- /dev/null +++ b/core/payments/application/use-cases/UpdatePaymentStatusUseCase.ts @@ -0,0 +1,65 @@ +/** + * Application Use Case: UpdatePaymentStatusUseCase + * + * Updates the status of a payment. + */ + +import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepository'; +import type { PaymentStatus } from '../../domain/entities/Payment'; +import type { + IUpdatePaymentStatusPresenter, + UpdatePaymentStatusResultDTO, + UpdatePaymentStatusViewModel, +} from '../presenters/IUpdatePaymentStatusPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export interface UpdatePaymentStatusInput { + paymentId: string; + status: PaymentStatus; +} + +export class UpdatePaymentStatusUseCase + implements UseCase +{ + constructor(private readonly paymentRepository: IPaymentRepository) {} + + async execute( + input: UpdatePaymentStatusInput, + presenter: IUpdatePaymentStatusPresenter, + ): Promise { + presenter.reset(); + + const { paymentId, status } = input; + + const payment = await this.paymentRepository.findById(paymentId); + if (!payment) { + throw new Error('Payment not found'); + } + + payment.status = status; + if (status === ('completed' as PaymentStatus)) { + payment.completedAt = new Date(); + } + + const updatedPayment = await this.paymentRepository.update(payment); + + const dto: UpdatePaymentStatusResultDTO = { + payment: { + id: updatedPayment.id, + type: updatedPayment.type, + amount: updatedPayment.amount, + platformFee: updatedPayment.platformFee, + netAmount: updatedPayment.netAmount, + payerId: updatedPayment.payerId, + payerType: updatedPayment.payerType, + leagueId: updatedPayment.leagueId, + seasonId: updatedPayment.seasonId, + status: updatedPayment.status, + createdAt: updatedPayment.createdAt, + completedAt: updatedPayment.completedAt, + }, + }; + + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/payments/application/use-cases/UpsertMembershipFeeUseCase.ts b/core/payments/application/use-cases/UpsertMembershipFeeUseCase.ts new file mode 100644 index 000000000..8293f1052 --- /dev/null +++ b/core/payments/application/use-cases/UpsertMembershipFeeUseCase.ts @@ -0,0 +1,76 @@ +/** + * Application Use Case: UpsertMembershipFeeUseCase + * + * Creates or updates membership fee configuration. + */ + +import type { IMembershipFeeRepository } from '../../domain/repositories/IMembershipFeeRepository'; +import type { MembershipFeeType, MembershipFee } from '../../domain/entities/MembershipFee'; +import type { + IUpsertMembershipFeePresenter, + UpsertMembershipFeeResultDTO, + UpsertMembershipFeeViewModel, +} from '../presenters/IUpsertMembershipFeePresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export interface UpsertMembershipFeeInput { + leagueId: string; + seasonId?: string; + type: MembershipFeeType; + amount: number; +} + +export class UpsertMembershipFeeUseCase + implements UseCase +{ + constructor(private readonly membershipFeeRepository: IMembershipFeeRepository) {} + + async execute( + input: UpsertMembershipFeeInput, + presenter: IUpsertMembershipFeePresenter, + ): Promise { + presenter.reset(); + + const { leagueId, seasonId, type, amount } = input; + + let existingFee = await this.membershipFeeRepository.findByLeagueId(leagueId); + + let fee: MembershipFee; + if (existingFee) { + existingFee.type = type; + existingFee.amount = amount; + existingFee.seasonId = seasonId; + existingFee.enabled = amount > 0; + existingFee.updatedAt = new Date(); + fee = await this.membershipFeeRepository.update(existingFee); + } else { + const id = `fee-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const newFee: MembershipFee = { + id, + leagueId, + seasonId, + type, + amount, + enabled: amount > 0, + createdAt: new Date(), + updatedAt: new Date(), + }; + fee = await this.membershipFeeRepository.create(newFee); + } + + const dto: UpsertMembershipFeeResultDTO = { + fee: { + id: fee.id, + leagueId: fee.leagueId, + seasonId: fee.seasonId, + type: fee.type, + amount: fee.amount, + enabled: fee.enabled, + createdAt: fee.createdAt, + updatedAt: fee.updatedAt, + }, + }; + + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/payments/application/use-cases/index.ts b/core/payments/application/use-cases/index.ts new file mode 100644 index 000000000..ef3af2637 --- /dev/null +++ b/core/payments/application/use-cases/index.ts @@ -0,0 +1,12 @@ +export * from './GetPaymentsUseCase'; +export * from './CreatePaymentUseCase'; +export * from './UpdatePaymentStatusUseCase'; +export * from './GetMembershipFeesUseCase'; +export * from './UpsertMembershipFeeUseCase'; +export * from './UpdateMemberPaymentUseCase'; +export * from './GetPrizesUseCase'; +export * from './CreatePrizeUseCase'; +export * from './AwardPrizeUseCase'; +export * from './DeletePrizeUseCase'; +export * from './GetWalletUseCase'; +export * from './ProcessWalletTransactionUseCase'; \ No newline at end of file diff --git a/core/payments/domain/entities/MemberPayment.ts b/core/payments/domain/entities/MemberPayment.ts new file mode 100644 index 000000000..659997428 --- /dev/null +++ b/core/payments/domain/entities/MemberPayment.ts @@ -0,0 +1,21 @@ +/** + * Domain Entity: MemberPayment + */ + +export enum MemberPaymentStatus { + PENDING = 'pending', + PAID = 'paid', + OVERDUE = 'overdue', +} + +export interface MemberPayment { + id: string; + feeId: string; + driverId: string; + amount: number; + platformFee: number; + netAmount: number; + status: MemberPaymentStatus; + dueDate: Date; + paidAt?: Date; +} \ No newline at end of file diff --git a/core/payments/domain/entities/MembershipFee.ts b/core/payments/domain/entities/MembershipFee.ts new file mode 100644 index 000000000..0e54b07d0 --- /dev/null +++ b/core/payments/domain/entities/MembershipFee.ts @@ -0,0 +1,20 @@ +/** + * Domain Entity: MembershipFee + */ + +export enum MembershipFeeType { + SEASON = 'season', + MONTHLY = 'monthly', + PER_RACE = 'per_race', +} + +export interface MembershipFee { + id: string; + leagueId: string; + seasonId?: string; + type: MembershipFeeType; + amount: number; + enabled: boolean; + createdAt: Date; + updatedAt: Date; +} \ No newline at end of file diff --git a/core/payments/domain/entities/Payment.ts b/core/payments/domain/entities/Payment.ts new file mode 100644 index 000000000..e6ad68c05 --- /dev/null +++ b/core/payments/domain/entities/Payment.ts @@ -0,0 +1,35 @@ +/** + * Domain Entity: Payment + */ + +export enum PaymentType { + SPONSORSHIP = 'sponsorship', + MEMBERSHIP_FEE = 'membership_fee', +} + +export enum PayerType { + SPONSOR = 'sponsor', + DRIVER = 'driver', +} + +export enum PaymentStatus { + PENDING = 'pending', + COMPLETED = 'completed', + FAILED = 'failed', + REFUNDED = 'refunded', +} + +export interface Payment { + id: string; + type: PaymentType; + amount: number; + platformFee: number; + netAmount: number; + payerId: string; + payerType: PayerType; + leagueId: string; + seasonId?: string; + status: PaymentStatus; + createdAt: Date; + completedAt?: Date; +} \ No newline at end of file diff --git a/core/payments/domain/entities/Prize.ts b/core/payments/domain/entities/Prize.ts new file mode 100644 index 000000000..8b7ce6240 --- /dev/null +++ b/core/payments/domain/entities/Prize.ts @@ -0,0 +1,24 @@ +/** + * Domain Entity: Prize + */ + +export enum PrizeType { + CASH = 'cash', + MERCHANDISE = 'merchandise', + OTHER = 'other', +} + +export interface Prize { + id: string; + leagueId: string; + seasonId: string; + position: number; + name: string; + amount: number; + type: PrizeType; + description?: string; + awarded: boolean; + awardedTo?: string; + awardedAt?: Date; + createdAt: Date; +} \ No newline at end of file diff --git a/core/payments/domain/entities/Wallet.ts b/core/payments/domain/entities/Wallet.ts new file mode 100644 index 000000000..bf3bf7379 --- /dev/null +++ b/core/payments/domain/entities/Wallet.ts @@ -0,0 +1,37 @@ +/** + * Domain Entity: Wallet + */ + +export interface Wallet { + id: string; + leagueId: string; + balance: number; + totalRevenue: number; + totalPlatformFees: number; + totalWithdrawn: number; + currency: string; + createdAt: Date; +} + +export enum TransactionType { + DEPOSIT = 'deposit', + WITHDRAWAL = 'withdrawal', + PLATFORM_FEE = 'platform_fee', +} + +export enum ReferenceType { + SPONSORSHIP = 'sponsorship', + MEMBERSHIP_FEE = 'membership_fee', + PRIZE = 'prize', +} + +export interface Transaction { + id: string; + walletId: string; + type: TransactionType; + amount: number; + description: string; + referenceId?: string; + referenceType?: ReferenceType; + createdAt: Date; +} \ No newline at end of file diff --git a/core/payments/domain/entities/index.ts b/core/payments/domain/entities/index.ts new file mode 100644 index 000000000..479ea69a1 --- /dev/null +++ b/core/payments/domain/entities/index.ts @@ -0,0 +1,5 @@ +export * from './Payment'; +export * from './MembershipFee'; +export * from './MemberPayment'; +export * from './Prize'; +export * from './Wallet'; \ No newline at end of file diff --git a/core/payments/domain/index.ts b/core/payments/domain/index.ts new file mode 100644 index 000000000..653c2d601 --- /dev/null +++ b/core/payments/domain/index.ts @@ -0,0 +1,2 @@ +export * from './entities'; +export * from './repositories'; \ No newline at end of file diff --git a/core/payments/domain/repositories/IMembershipFeeRepository.ts b/core/payments/domain/repositories/IMembershipFeeRepository.ts new file mode 100644 index 000000000..388763c74 --- /dev/null +++ b/core/payments/domain/repositories/IMembershipFeeRepository.ts @@ -0,0 +1,21 @@ +/** + * Repository Interface: IMembershipFeeRepository + */ + +import type { MembershipFee } from '../entities/MembershipFee'; +import type { MemberPayment } from '../entities/MemberPayment'; + +export interface IMembershipFeeRepository { + findById(id: string): Promise; + findByLeagueId(leagueId: string): Promise; + create(fee: MembershipFee): Promise; + update(fee: MembershipFee): Promise; +} + +export interface IMemberPaymentRepository { + findById(id: string): Promise; + findByFeeIdAndDriverId(feeId: string, driverId: string): Promise; + findByLeagueIdAndDriverId(leagueId: string, driverId: string, membershipFeeRepo: IMembershipFeeRepository): Promise; + create(payment: MemberPayment): Promise; + update(payment: MemberPayment): Promise; +} \ No newline at end of file diff --git a/core/payments/domain/repositories/IPaymentRepository.ts b/core/payments/domain/repositories/IPaymentRepository.ts new file mode 100644 index 000000000..e3f51740e --- /dev/null +++ b/core/payments/domain/repositories/IPaymentRepository.ts @@ -0,0 +1,15 @@ +/** + * Repository Interface: IPaymentRepository + */ + +import type { Payment, PaymentType } from '../entities/Payment'; + +export interface IPaymentRepository { + findById(id: string): Promise; + findByLeagueId(leagueId: string): Promise; + findByPayerId(payerId: string): Promise; + findByType(type: PaymentType): Promise; + findByFilters(filters: { leagueId?: string; payerId?: string; type?: PaymentType }): Promise; + create(payment: Payment): Promise; + update(payment: Payment): Promise; +} \ No newline at end of file diff --git a/core/payments/domain/repositories/IPrizeRepository.ts b/core/payments/domain/repositories/IPrizeRepository.ts new file mode 100644 index 000000000..462f3278e --- /dev/null +++ b/core/payments/domain/repositories/IPrizeRepository.ts @@ -0,0 +1,15 @@ +/** + * Repository Interface: IPrizeRepository + */ + +import type { Prize } from '../entities/Prize'; + +export interface IPrizeRepository { + findById(id: string): Promise; + findByLeagueId(leagueId: string): Promise; + findByLeagueIdAndSeasonId(leagueId: string, seasonId: string): Promise; + findByPosition(leagueId: string, seasonId: string, position: number): Promise; + create(prize: Prize): Promise; + update(prize: Prize): Promise; + delete(id: string): Promise; +} \ No newline at end of file diff --git a/core/payments/domain/repositories/IWalletRepository.ts b/core/payments/domain/repositories/IWalletRepository.ts new file mode 100644 index 000000000..25739afbc --- /dev/null +++ b/core/payments/domain/repositories/IWalletRepository.ts @@ -0,0 +1,18 @@ +/** + * Repository Interface: IWalletRepository + */ + +import type { Wallet, Transaction } from '../entities/Wallet'; + +export interface IWalletRepository { + findById(id: string): Promise; + findByLeagueId(leagueId: string): Promise; + create(wallet: Wallet): Promise; + update(wallet: Wallet): Promise; +} + +export interface ITransactionRepository { + findById(id: string): Promise; + findByWalletId(walletId: string): Promise; + create(transaction: Transaction): Promise; +} \ No newline at end of file diff --git a/core/payments/domain/repositories/index.ts b/core/payments/domain/repositories/index.ts new file mode 100644 index 000000000..5f0501ceb --- /dev/null +++ b/core/payments/domain/repositories/index.ts @@ -0,0 +1,4 @@ +export * from './IPaymentRepository'; +export * from './IMembershipFeeRepository'; +export * from './IPrizeRepository'; +export * from './IWalletRepository'; \ No newline at end of file diff --git a/core/payments/index.ts b/core/payments/index.ts new file mode 100644 index 000000000..9a29d54e4 --- /dev/null +++ b/core/payments/index.ts @@ -0,0 +1,2 @@ +export * from './domain'; +export * from './application'; \ No newline at end of file diff --git a/core/racing/application/index.ts b/core/racing/application/index.ts index a363c5d1b..172265d9e 100644 --- a/core/racing/application/index.ts +++ b/core/racing/application/index.ts @@ -18,6 +18,9 @@ export * from './use-cases/GetLeagueStandingsUseCase'; export * from './use-cases/GetLeagueDriverSeasonStatsUseCase'; export * from './use-cases/GetAllLeaguesWithCapacityUseCase'; export * from './use-cases/GetAllLeaguesWithCapacityAndScoringUseCase'; +export * from './use-cases/GetAllRacesUseCase'; +export * from './use-cases/GetTotalRacesUseCase'; +export * from './use-cases/ImportRaceResultsApiUseCase'; export * from './use-cases/ListLeagueScoringPresetsUseCase'; export * from './use-cases/GetLeagueScoringConfigUseCase'; export * from './use-cases/RecalculateChampionshipStandingsUseCase'; diff --git a/core/racing/application/presenters/ICreateSponsorPresenter.ts b/core/racing/application/presenters/ICreateSponsorPresenter.ts new file mode 100644 index 000000000..96da64ff9 --- /dev/null +++ b/core/racing/application/presenters/ICreateSponsorPresenter.ts @@ -0,0 +1,20 @@ +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; + +export interface SponsorDto { + id: string; + name: string; + contactEmail: string; + websiteUrl: string | undefined; + logoUrl: string | undefined; + createdAt: Date; +} + +export interface CreateSponsorViewModel { + sponsor: SponsorDto; +} + +export interface CreateSponsorResultDTO { + sponsor: SponsorDto; +} + +export interface ICreateSponsorPresenter extends Presenter {} \ No newline at end of file diff --git a/core/racing/application/presenters/IEntitySponsorshipPricingPresenter.ts b/core/racing/application/presenters/IEntitySponsorshipPricingPresenter.ts index 23bf55fda..787d5a904 100644 --- a/core/racing/application/presenters/IEntitySponsorshipPricingPresenter.ts +++ b/core/racing/application/presenters/IEntitySponsorshipPricingPresenter.ts @@ -1,5 +1,4 @@ +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; import type { GetEntitySponsorshipPricingResultDTO } from '../use-cases/GetEntitySponsorshipPricingUseCase'; -export interface IEntitySponsorshipPricingPresenter { - present(data: GetEntitySponsorshipPricingResultDTO | null): void; -} \ No newline at end of file +export interface IEntitySponsorshipPricingPresenter extends Presenter {} \ No newline at end of file diff --git a/core/racing/application/presenters/IGetAllRacesPresenter.ts b/core/racing/application/presenters/IGetAllRacesPresenter.ts new file mode 100644 index 000000000..548c69d4f --- /dev/null +++ b/core/racing/application/presenters/IGetAllRacesPresenter.ts @@ -0,0 +1,20 @@ +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; + +export interface RaceViewModel { + id: string; + name: string; + date: string; + leagueName?: string; +} + +export interface AllRacesPageViewModel { + races: RaceViewModel[]; + totalCount: number; +} + +export interface GetAllRacesResultDTO { + races: RaceViewModel[]; + totalCount: number; +} + +export interface IGetAllRacesPresenter extends Presenter {} \ No newline at end of file diff --git a/core/racing/application/presenters/IGetSponsorsPresenter.ts b/core/racing/application/presenters/IGetSponsorsPresenter.ts new file mode 100644 index 000000000..353374b96 --- /dev/null +++ b/core/racing/application/presenters/IGetSponsorsPresenter.ts @@ -0,0 +1,20 @@ +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; + +export interface SponsorDto { + id: string; + name: string; + contactEmail: string; + websiteUrl: string | undefined; + logoUrl: string | undefined; + createdAt: Date; +} + +export interface GetSponsorsViewModel { + sponsors: SponsorDto[]; +} + +export interface GetSponsorsResultDTO { + sponsors: SponsorDto[]; +} + +export interface IGetSponsorsPresenter extends Presenter {} \ No newline at end of file diff --git a/core/racing/application/presenters/IGetSponsorshipPricingPresenter.ts b/core/racing/application/presenters/IGetSponsorshipPricingPresenter.ts new file mode 100644 index 000000000..d0b326700 --- /dev/null +++ b/core/racing/application/presenters/IGetSponsorshipPricingPresenter.ts @@ -0,0 +1,18 @@ +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; + +export interface SponsorshipPricingItemDto { + id: string; + level: string; + price: number; + currency: string; +} + +export interface GetSponsorshipPricingResultDTO { + pricing: SponsorshipPricingItemDto[]; +} + +export interface GetSponsorshipPricingViewModel { + pricing: SponsorshipPricingItemDto[]; +} + +export interface IGetSponsorshipPricingPresenter extends Presenter {} \ No newline at end of file diff --git a/core/racing/application/presenters/IGetTotalRacesPresenter.ts b/core/racing/application/presenters/IGetTotalRacesPresenter.ts new file mode 100644 index 000000000..1cdb98508 --- /dev/null +++ b/core/racing/application/presenters/IGetTotalRacesPresenter.ts @@ -0,0 +1,11 @@ +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; + +export interface GetTotalRacesViewModel { + totalRaces: number; +} + +export interface GetTotalRacesResultDTO { + totalRaces: number; +} + +export interface IGetTotalRacesPresenter extends Presenter {} \ No newline at end of file diff --git a/core/racing/application/presenters/IImportRaceResultsApiPresenter.ts b/core/racing/application/presenters/IImportRaceResultsApiPresenter.ts new file mode 100644 index 000000000..f07c69ac5 --- /dev/null +++ b/core/racing/application/presenters/IImportRaceResultsApiPresenter.ts @@ -0,0 +1,19 @@ +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; + +export interface ImportRaceResultsSummaryViewModel { + success: boolean; + raceId: string; + driversProcessed: number; + resultsRecorded: number; + errors?: string[]; +} + +export interface ImportRaceResultsApiResultDTO { + success: boolean; + raceId: string; + driversProcessed: number; + resultsRecorded: number; + errors?: string[]; +} + +export interface IImportRaceResultsApiPresenter extends Presenter {} \ No newline at end of file diff --git a/core/racing/application/use-cases/CreateSponsorUseCase.ts b/core/racing/application/use-cases/CreateSponsorUseCase.ts new file mode 100644 index 000000000..dde48744b --- /dev/null +++ b/core/racing/application/use-cases/CreateSponsorUseCase.ts @@ -0,0 +1,61 @@ +/** + * Application Use Case: CreateSponsorUseCase + * + * Creates a new sponsor. + */ + +import { Sponsor, type SponsorProps } from '../../domain/entities/Sponsor'; +import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; +import type { + ICreateSponsorPresenter, + CreateSponsorResultDTO, + CreateSponsorViewModel, +} from '../presenters/ICreateSponsorPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export interface CreateSponsorInput { + name: string; + contactEmail: string; + websiteUrl?: string; + logoUrl?: string; +} + +export class CreateSponsorUseCase + implements UseCase +{ + constructor( + private readonly sponsorRepository: ISponsorRepository, + ) {} + + async execute( + input: CreateSponsorInput, + presenter: ICreateSponsorPresenter, + ): Promise { + presenter.reset(); + + const id = `sponsor-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + const sponsor = Sponsor.create({ + id, + name: input.name, + contactEmail: input.contactEmail, + ...(input.websiteUrl !== undefined ? { websiteUrl: input.websiteUrl } : {}), + ...(input.logoUrl !== undefined ? { logoUrl: input.logoUrl } : {}), + } as any); + + await this.sponsorRepository.create(sponsor); + + const dto: CreateSponsorResultDTO = { + sponsor: { + id: sponsor.id, + name: sponsor.name, + contactEmail: sponsor.contactEmail, + websiteUrl: sponsor.websiteUrl, + logoUrl: sponsor.logoUrl, + createdAt: sponsor.createdAt, + }, + }; + + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/racing/application/use-cases/GetAllRacesUseCase.ts b/core/racing/application/use-cases/GetAllRacesUseCase.ts new file mode 100644 index 000000000..ec09d7beb --- /dev/null +++ b/core/racing/application/use-cases/GetAllRacesUseCase.ts @@ -0,0 +1,34 @@ +import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { IGetAllRacesPresenter, GetAllRacesResultDTO, AllRacesPageViewModel } from '../presenters/IGetAllRacesPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export interface GetAllRacesUseCaseParams {} + +export class GetAllRacesUseCase implements UseCase { + constructor( + private readonly raceRepository: IRaceRepository, + private readonly leagueRepository: ILeagueRepository, + ) {} + + async execute(params: GetAllRacesUseCaseParams, presenter: IGetAllRacesPresenter): Promise { + const races = await this.raceRepository.findAll(); + const leagues = await this.leagueRepository.findAll(); + const leagueMap = new Map(leagues.map(league => [league.id, league.name])); + + const raceViewModels = races.map(race => ({ + id: race.id, + name: `Race ${race.id}`, // Placeholder, adjust based on domain + date: race.scheduledAt.toISOString(), + leagueName: leagueMap.get(race.leagueId) || 'Unknown League', + })); + + const dto: GetAllRacesResultDTO = { + races: raceViewModels, + totalCount: races.length, + }; + + presenter.reset(); + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.ts b/core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.ts index ababc3e9c..1825cc8f5 100644 --- a/core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.ts +++ b/core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.ts @@ -11,8 +11,7 @@ import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISe import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest'; import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship'; import type { IEntitySponsorshipPricingPresenter } from '../presenters/IEntitySponsorshipPricingPresenter'; -import type { AsyncUseCase } from '@gridpilot/shared/application'; -import type { Logger } from '../../../shared/src/logging/Logger'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; export interface GetEntitySponsorshipPricingDTO { entityType: SponsorableEntityType; @@ -41,35 +40,28 @@ export interface GetEntitySponsorshipPricingResultDTO { } export class GetEntitySponsorshipPricingUseCase - implements AsyncUseCase { + implements UseCase +{ constructor( private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository, private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository, private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository, - private readonly presenter: IEntitySponsorshipPricingPresenter, - private readonly logger: Logger, ) {} - async execute(dto: GetEntitySponsorshipPricingDTO): Promise { - this.logger.debug( - `Executing GetEntitySponsorshipPricingUseCase for entityType: ${dto.entityType}, entityId: ${dto.entityId}`, - { dto }, - ); + async execute( + dto: GetEntitySponsorshipPricingDTO, + presenter: IEntitySponsorshipPricingPresenter, + ): Promise { + presenter.reset(); try { const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId); if (!pricing) { - this.logger.warn( - `No pricing found for entityType: ${dto.entityType}, entityId: ${dto.entityId}. Presenting null.`, - { dto }, - ); - this.presenter.present(null); + presenter.present(null); return; } - this.logger.debug(`Found pricing for entityType: ${dto.entityType}, entityId: ${dto.entityId}`, { pricing }); - // Count pending requests by tier const pendingRequests = await this.sponsorshipRequestRepo.findPendingByEntity( dto.entityType, @@ -78,10 +70,6 @@ export class GetEntitySponsorshipPricingUseCase const pendingMainCount = pendingRequests.filter(r => r.tier === 'main').length; const pendingSecondaryCount = pendingRequests.filter(r => r.tier === 'secondary').length; - this.logger.debug( - `Pending requests counts: main=${pendingMainCount}, secondary=${pendingSecondaryCount}`, - ); - // Count filled slots (for seasons, check SeasonSponsorship table) let filledMainSlots = 0; let filledSecondarySlots = 0; @@ -91,9 +79,6 @@ export class GetEntitySponsorshipPricingUseCase const activeSponsorships = sponsorships.filter(s => s.isActive()); filledMainSlots = activeSponsorships.filter(s => s.tier === 'main').length; filledSecondarySlots = activeSponsorships.filter(s => s.tier === 'secondary').length; - this.logger.debug( - `Filled slots for season: main=${filledMainSlots}, secondary=${filledSecondarySlots}`, - ); } const result: GetEntitySponsorshipPricingResultDTO = { @@ -118,7 +103,6 @@ export class GetEntitySponsorshipPricingUseCase filledSlots: filledMainSlots, pendingRequests: pendingMainCount, }; - this.logger.debug(`Main slot pricing information processed`, { mainSlot: result.mainSlot }); } if (pricing.secondarySlots) { @@ -135,26 +119,10 @@ export class GetEntitySponsorshipPricingUseCase filledSlots: filledSecondarySlots, pendingRequests: pendingSecondaryCount, }; - this.logger.debug(`Secondary slot pricing information processed`, { - secondarySlot: result.secondarySlot, - }); } - this.logger.info( - `Successfully retrieved and processed entity sponsorship pricing for entityType: ${dto.entityType}, entityId: ${dto.entityId}`, - { result }, - ); - this.presenter.present(result); + presenter.present(result); } catch (error: unknown) { - let errorMessage = 'An unknown error occurred'; - if (error instanceof Error) { - errorMessage = error.message; - } - this.logger.error( - `Failed to get entity sponsorship pricing for entityType: ${dto.entityType}, entityId: ${dto.entityId}. Error: ${errorMessage}`, - { error, dto }, - ); - // Re-throw the error or present an error state if the presenter supports it throw error; } } diff --git a/core/racing/application/use-cases/GetSponsorsUseCase.ts b/core/racing/application/use-cases/GetSponsorsUseCase.ts new file mode 100644 index 000000000..0bafd94d7 --- /dev/null +++ b/core/racing/application/use-cases/GetSponsorsUseCase.ts @@ -0,0 +1,43 @@ +/** + * Application Use Case: GetSponsorsUseCase + * + * Retrieves all sponsors. + */ + +import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; +import type { + IGetSponsorsPresenter, + GetSponsorsResultDTO, + GetSponsorsViewModel, +} from '../presenters/IGetSponsorsPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export class GetSponsorsUseCase + implements UseCase +{ + constructor( + private readonly sponsorRepository: ISponsorRepository, + ) {} + + async execute( + _input: void, + presenter: IGetSponsorsPresenter, + ): Promise { + presenter.reset(); + + const sponsors = await this.sponsorRepository.findAll(); + + const dto: GetSponsorsResultDTO = { + sponsors: sponsors.map(sponsor => ({ + id: sponsor.id, + name: sponsor.name, + contactEmail: sponsor.contactEmail, + websiteUrl: sponsor.websiteUrl, + logoUrl: sponsor.logoUrl, + createdAt: sponsor.createdAt, + })), + }; + + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/racing/application/use-cases/GetSponsorshipPricingUseCase.ts b/core/racing/application/use-cases/GetSponsorshipPricingUseCase.ts new file mode 100644 index 000000000..65dec2a0b --- /dev/null +++ b/core/racing/application/use-cases/GetSponsorshipPricingUseCase.ts @@ -0,0 +1,35 @@ +/** + * Application Use Case: GetSponsorshipPricingUseCase + * + * Retrieves general sponsorship pricing tiers. + */ + +import type { + IGetSponsorshipPricingPresenter, + GetSponsorshipPricingResultDTO, + GetSponsorshipPricingViewModel, +} from '../presenters/IGetSponsorshipPricingPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export class GetSponsorshipPricingUseCase + implements UseCase +{ + constructor() {} + + async execute( + _input: void, + presenter: IGetSponsorshipPricingPresenter, + ): Promise { + presenter.reset(); + + const dto: GetSponsorshipPricingResultDTO = { + pricing: [ + { id: 'tier-bronze', level: 'Bronze', price: 100, currency: 'USD' }, + { id: 'tier-silver', level: 'Silver', price: 250, currency: 'USD' }, + { id: 'tier-gold', level: 'Gold', price: 500, currency: 'USD' }, + ], + }; + + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/racing/application/use-cases/GetTotalRacesUseCase.ts b/core/racing/application/use-cases/GetTotalRacesUseCase.ts new file mode 100644 index 000000000..529c8d5a5 --- /dev/null +++ b/core/racing/application/use-cases/GetTotalRacesUseCase.ts @@ -0,0 +1,20 @@ +import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import type { IGetTotalRacesPresenter, GetTotalRacesResultDTO, GetTotalRacesViewModel } from '../presenters/IGetTotalRacesPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export interface GetTotalRacesUseCaseParams {} + +export interface GetTotalRacesResultDTO { + totalRaces: number; +} + +export class GetTotalRacesUseCase implements UseCase { + constructor(private readonly raceRepository: IRaceRepository) {} + + async execute(params: GetTotalRacesUseCaseParams, presenter: IGetTotalRacesPresenter): Promise { + const races = await this.raceRepository.findAll(); + const dto: GetTotalRacesResultDTO = { totalRaces: races.length }; + presenter.reset(); + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/racing/application/use-cases/ImportRaceResultsApiUseCase.ts b/core/racing/application/use-cases/ImportRaceResultsApiUseCase.ts new file mode 100644 index 000000000..10559538b --- /dev/null +++ b/core/racing/application/use-cases/ImportRaceResultsApiUseCase.ts @@ -0,0 +1,25 @@ +import type { IImportRaceResultsApiPresenter, ImportRaceResultsApiResultDTO, ImportRaceResultsSummaryViewModel } from '../presenters/IImportRaceResultsApiPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export interface ImportRaceResultsApiParams { + raceId: string; + resultsFileContent: string; +} + +export class ImportRaceResultsApiUseCase implements UseCase { + constructor() {} // No repositories for mock + + async execute(params: ImportRaceResultsApiParams, presenter: IImportRaceResultsApiPresenter): Promise { + // Mock implementation + const dto: ImportRaceResultsApiResultDTO = { + success: true, + raceId: params.raceId, + driversProcessed: 10, + resultsRecorded: 10, + errors: [], + }; + + presenter.reset(); + presenter.present(dto); + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b5cbc0e92..aca8f0f44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "commander": "^11.0.0", "electron": "^39.2.7", "eslint": "^8.0.0", + "eslint-plugin-boundaries": "^5.3.1", "husky": "^9.1.7", "jsdom": "^22.1.0", "prettier": "^3.0.0", @@ -1036,6 +1037,23 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@boundaries/elements": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@boundaries/elements/-/elements-1.1.2.tgz", + "integrity": "sha512-DnGHL+v36YVMoWhWZqyJYVZ9dapNm7h4N3/P0lDPirJj0CHVPkjChMCCotj74cg6LW7iPJZFGrdEfh0X0g2bmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-import-resolver-node": "0.3.9", + "eslint-module-utils": "2.12.1", + "handlebars": "4.7.8", + "is-core-module": "2.16.1", + "micromatch": "4.0.8" + }, + "engines": { + "node": ">=18.18" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -8107,6 +8125,26 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-plugin-boundaries": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-boundaries/-/eslint-plugin-boundaries-5.3.1.tgz", + "integrity": "sha512-91StsOYtDyrna1fyRJ+1Ps5CnrfyFLbdCouPZ3E/o2cllLxJke3OoScdqjpBSl7pNEYbojhpNlurQAr30sf9Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@boundaries/elements": "1.1.2", + "chalk": "4.1.2", + "eslint-import-resolver-node": "0.3.9", + "eslint-module-utils": "2.12.1", + "micromatch": "4.0.8" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, "node_modules/eslint-plugin-import": { "version": "2.32.0", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", diff --git a/package.json b/package.json index 4c5bb727f..6485252ce 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "commander": "^11.0.0", "electron": "^39.2.7", "eslint": "^8.0.0", + "eslint-plugin-boundaries": "^5.3.1", "husky": "^9.1.7", "jsdom": "^22.1.0", "prettier": "^3.0.0",