From 775d41e05541349f210dc48ab1fd31da0d2e0ae9 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Tue, 16 Dec 2025 00:57:31 +0100 Subject: [PATCH] league service --- .eslintrc.json | 15 +- adapters/logging/ConsoleLogger.ts | 2 +- apps/api/jest.config.js | 1 + apps/api/src/app.module.ts | 2 + .../infrastructure/logging/LoggingModule.ts | 15 + .../src/modules/analytics/AnalyticsModule.ts | 37 +- .../modules/analytics/AnalyticsProviders.ts | 30 + apps/api/src/modules/auth/AuthModule.ts | 3 +- apps/api/src/modules/driver/DriverModule.ts | 3 +- .../api/src/modules/driver/DriverProviders.ts | 33 + .../src/modules/driver/DriverService.spec.ts | 187 +++++ apps/api/src/modules/driver/DriverService.ts | 83 +- .../CompleteOnboardingPresenter.spec.ts | 62 ++ .../presenters/CompleteOnboardingPresenter.ts | 23 + .../DriverRegistrationStatusPresenter.spec.ts | 46 ++ .../DriverRegistrationStatusPresenter.ts | 28 + .../presenters/DriverStatsPresenter.spec.ts | 41 + .../driver/presenters/DriverStatsPresenter.ts | 21 + .../DriversLeaderboardPresenter.spec.ts | 159 ++++ .../presenters/DriversLeaderboardPresenter.ts | 49 ++ .../modules/league/LeagueController.spec.ts | 43 + .../src/modules/league/LeagueController.ts | 45 +- apps/api/src/modules/league/LeagueModule.ts | 3 +- .../api/src/modules/league/LeagueProviders.ts | 64 +- .../src/modules/league/LeagueService.spec.ts | 170 ++++ apps/api/src/modules/league/LeagueService.ts | 278 +++++-- apps/api/src/modules/league/dto/LeagueDto.ts | 105 +++ .../AllLeaguesWithCapacityPresenter.ts | 30 + .../ApproveLeagueJoinRequestPresenter.ts | 18 + .../GetLeagueAdminPermissionsPresenter.ts | 17 + .../GetLeagueMembershipsPresenter.ts | 47 ++ .../GetLeagueOwnerSummaryPresenter.ts | 18 + .../presenters/GetLeagueProtestsPresenter.ts | 30 + .../presenters/GetLeagueSeasonsPresenter.ts | 27 + .../league/presenters/LeagueAdminPresenter.ts | 30 + .../presenters/LeagueConfigPresenter.ts | 109 +++ .../presenters/LeagueJoinRequestsPresenter.ts | 27 + .../presenters/LeagueSchedulePresenter.ts | 23 + .../presenters/LeagueStandingsPresenter.ts | 27 + .../league/presenters/LeagueStatsPresenter.ts | 17 + .../RejectLeagueJoinRequestPresenter.ts | 18 + .../presenters/RemoveLeagueMemberPresenter.ts | 18 + .../presenters/TotalLeaguesPresenter.ts | 20 + .../UpdateLeagueMemberRolePresenter.ts | 18 + apps/api/src/modules/media/MediaModule.ts | 3 +- apps/api/src/modules/media/MediaProviders.ts | 2 +- .../src/modules/payments/PaymentsModule.ts | 3 +- .../src/modules/payments/PaymentsProviders.ts | 2 +- apps/api/src/modules/race/RaceModule.ts | 3 +- apps/api/src/modules/sponsor/SponsorModule.ts | 3 +- apps/api/src/modules/team/TeamModule.ts | 3 +- apps/api/src/modules/team/TeamProviders.ts | 53 +- apps/api/src/modules/team/TeamService.spec.ts | 168 ++++ apps/api/src/modules/team/TeamService.ts | 67 +- apps/api/src/modules/team/dto/TeamDto.ts | 3 +- .../team/presenters/AllTeamsPresenter.ts | 31 + .../team/presenters/DriverTeamPresenter.ts | 42 + apps/api/tsconfig.json | 12 + apps/website/app/layout.tsx | 2 + .../components/leagues/LeagueSchedule.tsx | 8 +- .../components/leagues/ScheduleRaceForm.tsx | 11 +- apps/website/lib/app.module.ts | 25 + apps/website/lib/di-setup.ts | 25 + .../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 + .../lib/presenters/LeagueSchedulePresenter.ts | 168 ++-- .../lib/services/LeagueMembershipService.ts | 101 +++ .../ports/AutomationLifecycleEmitterPort.ts | 8 + .../services/OverlaySyncService.ts | 6 +- .../ports/LeagueScoringPresetProvider.ts | 3 + .../IApproveLeagueJoinRequestPresenter.ts | 13 + .../ICompleteDriverOnboardingPresenter.ts | 17 + .../IGetLeagueAdminPermissionsPresenter.ts | 13 + .../presenters/IGetLeagueAdminPresenter.ts | 13 + .../IGetLeagueJoinRequestsPresenter.ts | 21 + .../IGetLeagueMembershipsPresenter.ts | 21 + .../IGetLeagueOwnerSummaryPresenter.ts | 17 + .../presenters/IGetLeagueProtestsPresenter.ts | 15 + .../presenters/IGetLeagueSchedulePresenter.ts | 19 + .../presenters/IGetLeagueSeasonsPresenter.ts | 21 + .../presenters/IGetTotalLeaguesPresenter.ts | 11 + .../presenters/ILeagueStandingsPresenter.ts | 11 +- .../presenters/ILeagueStatsPresenter.ts | 28 +- .../IRejectLeagueJoinRequestPresenter.ts | 13 + .../IRemoveLeagueMemberPresenter.ts | 11 + .../presenters/ITotalDriversPresenter.ts | 13 + .../IUpdateLeagueMemberRolePresenter.ts | 11 + .../ApproveLeagueJoinRequestUseCase.ts | 36 + .../CompleteDriverOnboardingUseCase.ts | 60 ++ ...CreateLeagueWithSeasonAndScoringUseCase.ts | 28 +- .../GetLeagueAdminPermissionsUseCase.ts | 42 + .../use-cases/GetLeagueJoinRequestsUseCase.ts | 33 + .../use-cases/GetLeagueMembershipsUseCase.ts | 41 + .../use-cases/GetLeagueOwnerSummaryUseCase.ts | 23 + .../use-cases/GetLeagueProtestsUseCase.ts | 58 ++ .../use-cases/GetLeagueScheduleUseCase.ts | 32 + .../use-cases/GetLeagueSeasonsUseCase.ts | 22 + .../use-cases/GetLeagueStandingsUseCase.ts | 10 +- .../use-cases/GetLeagueStatsUseCase.ts | 115 +-- .../use-cases/GetTotalDriversUseCase.ts | 23 + .../use-cases/GetTotalLeaguesUseCase.ts | 20 + .../IsDriverRegisteredForRaceUseCase.ts | 17 +- .../RejectLeagueJoinRequestUseCase.ts | 23 + .../use-cases/RemoveLeagueMemberUseCase.ts | 31 + .../UpdateLeagueMemberRoleUseCase.ts | 32 + core/shared/package.json | 23 + package-lock.json | 751 ++++-------------- package.json | 3 + .../CompleteDriverOnboardingUseCase.spec.ts | 102 +++ .../GetTotalDriversUseCase.spec.ts | 33 + .../ApproveLeagueJoinRequestUseCase.spec.ts | 45 ++ .../GetLeagueJoinRequestsUseCase.spec.ts | 46 ++ .../RejectLeagueJoinRequestUseCase.spec.ts | 26 + .../RemoveLeagueMemberUseCase.spec.ts | 43 + .../UpdateLeagueMemberRoleUseCase.spec.ts | 44 + 130 files changed, 4077 insertions(+), 1036 deletions(-) create mode 100644 apps/api/src/infrastructure/logging/LoggingModule.ts create mode 100644 apps/api/src/modules/analytics/AnalyticsProviders.ts create mode 100644 apps/api/src/modules/driver/DriverService.spec.ts create mode 100644 apps/api/src/modules/driver/presenters/CompleteOnboardingPresenter.spec.ts create mode 100644 apps/api/src/modules/driver/presenters/CompleteOnboardingPresenter.ts create mode 100644 apps/api/src/modules/driver/presenters/DriverRegistrationStatusPresenter.spec.ts create mode 100644 apps/api/src/modules/driver/presenters/DriverRegistrationStatusPresenter.ts create mode 100644 apps/api/src/modules/driver/presenters/DriverStatsPresenter.spec.ts create mode 100644 apps/api/src/modules/driver/presenters/DriverStatsPresenter.ts create mode 100644 apps/api/src/modules/driver/presenters/DriversLeaderboardPresenter.spec.ts create mode 100644 apps/api/src/modules/driver/presenters/DriversLeaderboardPresenter.ts create mode 100644 apps/api/src/modules/league/LeagueController.spec.ts create mode 100644 apps/api/src/modules/league/LeagueService.spec.ts create mode 100644 apps/api/src/modules/league/presenters/AllLeaguesWithCapacityPresenter.ts create mode 100644 apps/api/src/modules/league/presenters/ApproveLeagueJoinRequestPresenter.ts create mode 100644 apps/api/src/modules/league/presenters/GetLeagueAdminPermissionsPresenter.ts create mode 100644 apps/api/src/modules/league/presenters/GetLeagueMembershipsPresenter.ts create mode 100644 apps/api/src/modules/league/presenters/GetLeagueOwnerSummaryPresenter.ts create mode 100644 apps/api/src/modules/league/presenters/GetLeagueProtestsPresenter.ts create mode 100644 apps/api/src/modules/league/presenters/GetLeagueSeasonsPresenter.ts create mode 100644 apps/api/src/modules/league/presenters/LeagueAdminPresenter.ts create mode 100644 apps/api/src/modules/league/presenters/LeagueConfigPresenter.ts create mode 100644 apps/api/src/modules/league/presenters/LeagueJoinRequestsPresenter.ts create mode 100644 apps/api/src/modules/league/presenters/LeagueSchedulePresenter.ts create mode 100644 apps/api/src/modules/league/presenters/LeagueStandingsPresenter.ts create mode 100644 apps/api/src/modules/league/presenters/LeagueStatsPresenter.ts create mode 100644 apps/api/src/modules/league/presenters/RejectLeagueJoinRequestPresenter.ts create mode 100644 apps/api/src/modules/league/presenters/RemoveLeagueMemberPresenter.ts create mode 100644 apps/api/src/modules/league/presenters/TotalLeaguesPresenter.ts create mode 100644 apps/api/src/modules/league/presenters/UpdateLeagueMemberRolePresenter.ts create mode 100644 apps/api/src/modules/team/TeamService.spec.ts create mode 100644 apps/api/src/modules/team/presenters/AllTeamsPresenter.ts create mode 100644 apps/api/src/modules/team/presenters/DriverTeamPresenter.ts create mode 100644 apps/website/lib/app.module.ts create mode 100644 apps/website/lib/di-setup.ts create mode 100644 apps/website/lib/modules/analytics/AnalyticsModule.ts create mode 100644 apps/website/lib/modules/analytics/AnalyticsProviders.ts create mode 100644 apps/website/lib/modules/auth/AuthModule.ts create mode 100644 apps/website/lib/modules/auth/AuthProviders.ts create mode 100644 apps/website/lib/modules/driver/DriverModule.ts create mode 100644 apps/website/lib/modules/driver/DriverProviders.ts create mode 100644 apps/website/lib/modules/league/LeagueModule.ts create mode 100644 apps/website/lib/modules/league/LeagueProviders.ts create mode 100644 apps/website/lib/modules/logging/LoggingModule.ts create mode 100644 apps/website/lib/modules/media/MediaModule.ts create mode 100644 apps/website/lib/modules/media/MediaProviders.ts create mode 100644 apps/website/lib/modules/race/RaceModule.ts create mode 100644 apps/website/lib/modules/race/RaceProviders.ts create mode 100644 apps/website/lib/modules/sponsor/SponsorModule.ts create mode 100644 apps/website/lib/modules/sponsor/SponsorProviders.ts create mode 100644 apps/website/lib/modules/team/TeamModule.ts create mode 100644 apps/website/lib/modules/team/TeamProviders.ts create mode 100644 apps/website/lib/services/LeagueMembershipService.ts create mode 100644 core/automation/application/ports/AutomationLifecycleEmitterPort.ts create mode 100644 core/racing/application/presenters/IApproveLeagueJoinRequestPresenter.ts create mode 100644 core/racing/application/presenters/ICompleteDriverOnboardingPresenter.ts create mode 100644 core/racing/application/presenters/IGetLeagueAdminPermissionsPresenter.ts create mode 100644 core/racing/application/presenters/IGetLeagueAdminPresenter.ts create mode 100644 core/racing/application/presenters/IGetLeagueJoinRequestsPresenter.ts create mode 100644 core/racing/application/presenters/IGetLeagueMembershipsPresenter.ts create mode 100644 core/racing/application/presenters/IGetLeagueOwnerSummaryPresenter.ts create mode 100644 core/racing/application/presenters/IGetLeagueProtestsPresenter.ts create mode 100644 core/racing/application/presenters/IGetLeagueSchedulePresenter.ts create mode 100644 core/racing/application/presenters/IGetLeagueSeasonsPresenter.ts create mode 100644 core/racing/application/presenters/IGetTotalLeaguesPresenter.ts create mode 100644 core/racing/application/presenters/IRejectLeagueJoinRequestPresenter.ts create mode 100644 core/racing/application/presenters/IRemoveLeagueMemberPresenter.ts create mode 100644 core/racing/application/presenters/ITotalDriversPresenter.ts create mode 100644 core/racing/application/presenters/IUpdateLeagueMemberRolePresenter.ts create mode 100644 core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase.ts create mode 100644 core/racing/application/use-cases/CompleteDriverOnboardingUseCase.ts create mode 100644 core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase.ts create mode 100644 core/racing/application/use-cases/GetLeagueJoinRequestsUseCase.ts create mode 100644 core/racing/application/use-cases/GetLeagueMembershipsUseCase.ts create mode 100644 core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase.ts create mode 100644 core/racing/application/use-cases/GetLeagueProtestsUseCase.ts create mode 100644 core/racing/application/use-cases/GetLeagueScheduleUseCase.ts create mode 100644 core/racing/application/use-cases/GetLeagueSeasonsUseCase.ts create mode 100644 core/racing/application/use-cases/GetTotalDriversUseCase.ts create mode 100644 core/racing/application/use-cases/GetTotalLeaguesUseCase.ts create mode 100644 core/racing/application/use-cases/RejectLeagueJoinRequestUseCase.ts create mode 100644 core/racing/application/use-cases/RemoveLeagueMemberUseCase.ts create mode 100644 core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase.ts create mode 100644 core/shared/package.json create mode 100644 tests/application/CompleteDriverOnboardingUseCase.spec.ts create mode 100644 tests/application/GetTotalDriversUseCase.spec.ts create mode 100644 tests/racing-application/ApproveLeagueJoinRequestUseCase.spec.ts create mode 100644 tests/racing-application/GetLeagueJoinRequestsUseCase.spec.ts create mode 100644 tests/racing-application/RejectLeagueJoinRequestUseCase.spec.ts create mode 100644 tests/racing-application/RemoveLeagueMemberUseCase.spec.ts create mode 100644 tests/racing-application/UpdateLeagueMemberRoleUseCase.spec.ts diff --git a/.eslintrc.json b/.eslintrc.json index dbbe2ed5c..d8617afb7 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -15,9 +15,18 @@ "plugins": ["@typescript-eslint"], "extends": [], "rules": { - "@typescript-eslint/no-explicit-any": "error" + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-unused-vars": [ + "error", + { + "args": "all", + "argsIgnorePattern": "^$", + "vars": "all", + "varsIgnorePattern": "^$", + "caughtErrors": "all" + } + ] } } ] -} - \ No newline at end of file +} \ No newline at end of file diff --git a/adapters/logging/ConsoleLogger.ts b/adapters/logging/ConsoleLogger.ts index 40efbdaaf..1008cbbbf 100644 --- a/adapters/logging/ConsoleLogger.ts +++ b/adapters/logging/ConsoleLogger.ts @@ -1,4 +1,4 @@ -import { Logger } from "@gridpilot/core/shared/application"; +import { ILogger as Logger } from "@gridpilot/core/shared/application"; export class ConsoleLogger implements Logger { debug(message: string, ...args: any[]): void { diff --git a/apps/api/jest.config.js b/apps/api/jest.config.js index a498d6175..681363423 100644 --- a/apps/api/jest.config.js +++ b/apps/api/jest.config.js @@ -13,5 +13,6 @@ module.exports = { testRegex: '.*\\.spec\\.ts$', moduleNameMapper: { '^@gridpilot/(.*)$': '/../../core/$1', // Corrected path + '^adapters/(.*)$': '/../../adapters/$1', }, }; diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 996c0986c..405931095 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -4,6 +4,7 @@ import { HelloController } from './presentation/hello.controller'; import { HelloService } from './application/hello/hello.service'; import { AnalyticsModule } from './modules/analytics/AnalyticsModule'; import { DatabaseModule } from './infrastructure/database/database.module'; +import { LoggingModule } from './infrastructure/logging/LoggingModule'; import { AuthModule } from './modules/auth/AuthModule'; import { LeagueModule } from './modules/league/LeagueModule'; import { RaceModule } from './modules/race/RaceModule'; @@ -16,6 +17,7 @@ import { PaymentsModule } from './modules/payments/PaymentsModule'; @Module({ imports: [ DatabaseModule, + LoggingModule, AnalyticsModule, AuthModule, LeagueModule, diff --git a/apps/api/src/infrastructure/logging/LoggingModule.ts b/apps/api/src/infrastructure/logging/LoggingModule.ts new file mode 100644 index 000000000..348b82112 --- /dev/null +++ b/apps/api/src/infrastructure/logging/LoggingModule.ts @@ -0,0 +1,15 @@ +import { Global, Module } from '@nestjs/common'; +import { Logger } from '@gridpilot/shared/application/Logger'; +import { ConsoleLogger } from '@gridpilot/adapters/logging/ConsoleLogger'; + +@Global() +@Module({ + providers: [ + { + provide: 'Logger', + useClass: ConsoleLogger, + }, + ], + exports: ['Logger'], +}) +export class LoggingModule {} \ No newline at end of file diff --git a/apps/api/src/modules/analytics/AnalyticsModule.ts b/apps/api/src/modules/analytics/AnalyticsModule.ts index e2f142f71..d1482fc71 100644 --- a/apps/api/src/modules/analytics/AnalyticsModule.ts +++ b/apps/api/src/modules/analytics/AnalyticsModule.ts @@ -1,43 +1,12 @@ import { Module } from '@nestjs/common'; import { AnalyticsController } from './AnalyticsController'; import { AnalyticsService } from './AnalyticsService'; - -const Logger_TOKEN = 'Logger_TOKEN'; -const IPAGE_VIEW_REPO_TOKEN = 'IPageViewRepository_TOKEN'; -const IENGAGEMENT_REPO_TOKEN = 'IEngagementRepository_TOKEN'; - -import { Logger } from '@gridpilot/shared/logging/Logger'; -import { IPageViewRepository } from '@gridpilot/analytics/application/repositories/IPageViewRepository'; -import { IEngagementRepository } from '@gridpilot/analytics/domain/repositories/IEngagementRepository'; - -import { ConsoleLogger } from '../../../../adapters/logging/ConsoleLogger'; -import { InMemoryPageViewRepository } from '../../../../adapters/analytics/persistence/inmemory/InMemoryPageViewRepository'; -import { InMemoryEngagementRepository } from '../../../../adapters/analytics/persistence/inmemory/InMemoryEngagementRepository'; +import { AnalyticsProviders } from './AnalyticsProviders'; @Module({ imports: [], controllers: [AnalyticsController], - providers: [ - AnalyticsService, - { - provide: Logger_TOKEN, - useClass: ConsoleLogger, - }, - { - provide: IPAGE_VIEW_REPO_TOKEN, - useClass: InMemoryPageViewRepository, - }, - { - provide: IENGAGEMENT_REPO_TOKEN, - useExisting: InMemoryEngagementRepository, // Assuming TypeOrmEngagementRepository is not available - }, - // No need for useExisting here if the original intent was to inject the concrete class when providing the TOKEN - ], - exports: [ - AnalyticsService, - Logger_TOKEN, - IPAGE_VIEW_REPO_TOKEN, - IENGAGEMENT_REPO_TOKEN, - ], + providers: AnalyticsProviders, + exports: [AnalyticsService], }) export class AnalyticsModule {} diff --git a/apps/api/src/modules/analytics/AnalyticsProviders.ts b/apps/api/src/modules/analytics/AnalyticsProviders.ts new file mode 100644 index 000000000..3ec6cc360 --- /dev/null +++ b/apps/api/src/modules/analytics/AnalyticsProviders.ts @@ -0,0 +1,30 @@ +import { Provider } from '@nestjs/common'; +import { AnalyticsService } from './AnalyticsService'; + +const Logger_TOKEN = 'Logger_TOKEN'; +const IPAGE_VIEW_REPO_TOKEN = 'IPageViewRepository_TOKEN'; +const IENGAGEMENT_REPO_TOKEN = 'IEngagementRepository_TOKEN'; + +import { Logger } from '@gridpilot/shared/logging/Logger'; +import { IPageViewRepository } from '@gridpilot/analytics/application/repositories/IPageViewRepository'; +import { IEngagementRepository } from '@gridpilot/analytics/domain/repositories/IEngagementRepository'; + +import { ConsoleLogger } from '../../../../adapters/logging/ConsoleLogger'; +import { InMemoryPageViewRepository } from '../../../../adapters/analytics/persistence/inmemory/InMemoryPageViewRepository'; +import { InMemoryEngagementRepository } from '../../../../adapters/analytics/persistence/inmemory/InMemoryEngagementRepository'; + +export const AnalyticsProviders: Provider[] = [ + AnalyticsService, + { + provide: Logger_TOKEN, + useClass: ConsoleLogger, + }, + { + provide: IPAGE_VIEW_REPO_TOKEN, + useClass: InMemoryPageViewRepository, + }, + { + provide: IENGAGEMENT_REPO_TOKEN, + useClass: InMemoryEngagementRepository, + }, +]; \ No newline at end of file diff --git a/apps/api/src/modules/auth/AuthModule.ts b/apps/api/src/modules/auth/AuthModule.ts index 8e779f7fc..9920bfac2 100644 --- a/apps/api/src/modules/auth/AuthModule.ts +++ b/apps/api/src/modules/auth/AuthModule.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { AuthService } from './AuthService'; import { AuthController } from './AuthController'; +import { AuthProviders } from './AuthProviders'; @Module({ controllers: [AuthController], - providers: [AuthService], + providers: AuthProviders, exports: [AuthService], }) export class AuthModule {} diff --git a/apps/api/src/modules/driver/DriverModule.ts b/apps/api/src/modules/driver/DriverModule.ts index b78a20a7a..bd2bfdb55 100644 --- a/apps/api/src/modules/driver/DriverModule.ts +++ b/apps/api/src/modules/driver/DriverModule.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { DriverService } from './DriverService'; import { DriverController } from './DriverController'; +import { DriverProviders } from './DriverProviders'; @Module({ controllers: [DriverController], - providers: [DriverService], + providers: DriverProviders, exports: [DriverService], }) export class DriverModule {} diff --git a/apps/api/src/modules/driver/DriverProviders.ts b/apps/api/src/modules/driver/DriverProviders.ts index 00c76be07..0dff636da 100644 --- a/apps/api/src/modules/driver/DriverProviders.ts +++ b/apps/api/src/modules/driver/DriverProviders.ts @@ -11,6 +11,11 @@ import { IRaceRegistrationRepository } from '../../../../core/racing/domain/repo import { INotificationPreferenceRepository } from '../../../../core/notifications/domain/repositories/INotificationPreferenceRepository'; import { Logger } from "@gridpilot/core/shared/application"; +// Import use cases +import { GetDriversLeaderboardUseCase } from '../../../../core/racing/application/use-cases/GetDriversLeaderboardUseCase'; +import { GetTotalDriversUseCase } from '../../../../core/racing/application/use-cases/GetTotalDriversUseCase'; +import { CompleteDriverOnboardingUseCase } from '../../../../core/racing/application/use-cases/CompleteDriverOnboardingUseCase'; + // Import concrete in-memory implementations import { InMemoryDriverRepository } from '../../../adapters/racing/persistence/inmemory/InMemoryDriverRepository'; import { InMemoryRankingService } from '../../../adapters/racing/services/InMemoryRankingService'; @@ -31,6 +36,12 @@ export const RACE_REGISTRATION_REPOSITORY_TOKEN = 'IRaceRegistrationRepository'; export const NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN = 'INotificationPreferenceRepository'; export const LOGGER_TOKEN = 'Logger'; // Already defined in AuthProviders, but good to have here too +// Use case tokens +export const GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN = 'GetDriversLeaderboardUseCase'; +export const GET_TOTAL_DRIVERS_USE_CASE_TOKEN = 'GetTotalDriversUseCase'; +export const COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN = 'CompleteDriverOnboardingUseCase'; +export const IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN = 'IsDriverRegisteredForRaceUseCase'; + export const DriverProviders: Provider[] = [ DriverService, // Provide the service itself { @@ -72,4 +83,26 @@ export const DriverProviders: Provider[] = [ provide: LOGGER_TOKEN, useClass: ConsoleLogger, }, + // Use cases + { + provide: GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN, + useFactory: (driverRepo: IDriverRepository, rankingService: IRankingService, driverStatsService: IDriverStatsService, imageService: IImageServicePort) => + new GetDriversLeaderboardUseCase(driverRepo, rankingService, driverStatsService, imageService), + inject: [DRIVER_REPOSITORY_TOKEN, RANKING_SERVICE_TOKEN, DRIVER_STATS_SERVICE_TOKEN, IMAGE_SERVICE_PORT_TOKEN], + }, + { + provide: GET_TOTAL_DRIVERS_USE_CASE_TOKEN, + useFactory: (driverRepo: IDriverRepository) => new GetTotalDriversUseCase(driverRepo), + inject: [DRIVER_REPOSITORY_TOKEN], + }, + { + provide: COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN, + useFactory: (driverRepo: IDriverRepository) => new CompleteDriverOnboardingUseCase(driverRepo), + inject: [DRIVER_REPOSITORY_TOKEN], + }, + { + provide: IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN, + useFactory: (registrationRepo: IRaceRegistrationRepository) => new IsDriverRegisteredForRaceUseCase(registrationRepo), + inject: [RACE_REGISTRATION_REPOSITORY_TOKEN], + }, ]; diff --git a/apps/api/src/modules/driver/DriverService.spec.ts b/apps/api/src/modules/driver/DriverService.spec.ts new file mode 100644 index 000000000..cee126b9b --- /dev/null +++ b/apps/api/src/modules/driver/DriverService.spec.ts @@ -0,0 +1,187 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DriverService } from './DriverService'; +import { GetDriversLeaderboardUseCase } from '../../../../core/racing/application/use-cases/GetDriversLeaderboardUseCase'; +import { GetTotalDriversUseCase } from '../../../../core/racing/application/use-cases/GetTotalDriversUseCase'; +import { CompleteDriverOnboardingUseCase } from '../../../../core/racing/application/use-cases/CompleteDriverOnboardingUseCase'; +import { IsDriverRegisteredForRaceUseCase } from '../../../../core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase'; +import { Logger } from '../../../../core/shared/logging/Logger'; + +describe('DriverService', () => { + let service: DriverService; + let getDriversLeaderboardUseCase: jest.Mocked; + let getTotalDriversUseCase: jest.Mocked; + let completeDriverOnboardingUseCase: jest.Mocked; + let isDriverRegisteredForRaceUseCase: jest.Mocked; + let logger: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DriverService, + { + provide: 'GetDriversLeaderboardUseCase', + useValue: { + execute: jest.fn(), + }, + }, + { + provide: 'GetTotalDriversUseCase', + useValue: { + execute: jest.fn(), + }, + }, + { + provide: 'CompleteDriverOnboardingUseCase', + useValue: { + execute: jest.fn(), + }, + }, + { + provide: 'IsDriverRegisteredForRaceUseCase', + useValue: { + execute: jest.fn(), + }, + }, + { + provide: 'Logger', + useValue: { + debug: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(DriverService); + getDriversLeaderboardUseCase = module.get('GetDriversLeaderboardUseCase'); + getTotalDriversUseCase = module.get('GetTotalDriversUseCase'); + completeDriverOnboardingUseCase = module.get('CompleteDriverOnboardingUseCase'); + isDriverRegisteredForRaceUseCase = module.get('IsDriverRegisteredForRaceUseCase'); + logger = module.get('Logger'); + }); + + describe('getDriversLeaderboard', () => { + it('should call GetDriversLeaderboardUseCase and return the view model', async () => { + const mockViewModel = { + drivers: [ + { + id: 'driver-1', + name: 'Driver 1', + rating: 2500, + skillLevel: 'Pro', + nationality: 'DE', + racesCompleted: 50, + wins: 10, + podiums: 20, + isActive: true, + rank: 1, + avatarUrl: 'https://example.com/avatar1.png', + }, + ], + totalRaces: 50, + totalWins: 10, + activeCount: 1, + }; + + const mockPresenter = { + viewModel: mockViewModel, + }; + + getDriversLeaderboardUseCase.execute.mockImplementation(async (input, presenter) => { + Object.assign(presenter, mockPresenter); + }); + + const result = await service.getDriversLeaderboard(); + + expect(getDriversLeaderboardUseCase.execute).toHaveBeenCalledWith(undefined, expect.any(Object)); + expect(logger.debug).toHaveBeenCalledWith('[DriverService] Fetching drivers leaderboard.'); + expect(result).toEqual(mockViewModel); + }); + }); + + describe('getTotalDrivers', () => { + it('should call GetTotalDriversUseCase and return the view model', async () => { + const mockViewModel = { totalDrivers: 5 }; + + const mockPresenter = { + viewModel: mockViewModel, + }; + + getTotalDriversUseCase.execute.mockImplementation(async (input, presenter) => { + Object.assign(presenter, mockPresenter); + }); + + const result = await service.getTotalDrivers(); + + expect(getTotalDriversUseCase.execute).toHaveBeenCalledWith(undefined, expect.any(Object)); + expect(logger.debug).toHaveBeenCalledWith('[DriverService] Fetching total drivers count.'); + expect(result).toEqual(mockViewModel); + }); + }); + + describe('completeOnboarding', () => { + it('should call CompleteDriverOnboardingUseCase and return the view model', async () => { + const input = { + firstName: 'John', + lastName: 'Doe', + displayName: 'John Doe', + country: 'US', + timezone: 'America/New_York', + bio: 'Racing enthusiast', + }; + + const mockViewModel = { + success: true, + driverId: 'user-123', + }; + + const mockPresenter = { + viewModel: mockViewModel, + }; + + completeDriverOnboardingUseCase.execute.mockImplementation(async (input, presenter) => { + Object.assign(presenter, mockPresenter); + }); + + const result = await service.completeOnboarding('user-123', input); + + expect(completeDriverOnboardingUseCase.execute).toHaveBeenCalledWith( + { + userId: 'user-123', + ...input, + }, + expect.any(Object) + ); + expect(logger.debug).toHaveBeenCalledWith('Completing onboarding for user:', 'user-123'); + expect(result).toEqual(mockViewModel); + }); + }); + + describe('getDriverRegistrationStatus', () => { + it('should call IsDriverRegisteredForRaceUseCase and return the view model', async () => { + const query = { + driverId: 'driver-1', + raceId: 'race-1', + }; + + const mockViewModel = { + isRegistered: true, + raceId: 'race-1', + driverId: 'driver-1', + }; + + const mockPresenter = { + viewModel: mockViewModel, + }; + + isDriverRegisteredForRaceUseCase.execute.mockImplementation(async (params, presenter) => { + Object.assign(presenter, mockPresenter); + }); + + const result = await service.getDriverRegistrationStatus(query); + + expect(isDriverRegisteredForRaceUseCase.execute).toHaveBeenCalledWith(query, expect.any(Object)); + expect(logger.debug).toHaveBeenCalledWith('Checking driver registration status:', query); + expect(result).toEqual(mockViewModel); + }); + }); +}); \ No newline at end of file diff --git a/apps/api/src/modules/driver/DriverService.ts b/apps/api/src/modules/driver/DriverService.ts index d7bd8a60b..e80de1177 100644 --- a/apps/api/src/modules/driver/DriverService.ts +++ b/apps/api/src/modules/driver/DriverService.ts @@ -1,46 +1,69 @@ -import { Injectable } from '@nestjs/common'; -import { DriversLeaderboardViewModel, DriverStatsDto, CompleteOnboardingInput, CompleteOnboardingOutput, GetDriverRegistrationStatusQuery, DriverRegistrationStatusViewModel, DriverLeaderboardItemViewModel } from './dto/DriverDto'; +import { Injectable, Inject } from '@nestjs/common'; +import { DriversLeaderboardViewModel, DriverStatsDto, CompleteOnboardingInput, CompleteOnboardingOutput, GetDriverRegistrationStatusQuery, DriverRegistrationStatusViewModel } from './dto/DriverDto'; + +// Use cases +import { GetDriversLeaderboardUseCase } from '../../../../core/racing/application/use-cases/GetDriversLeaderboardUseCase'; +import { GetTotalDriversUseCase } from '../../../../core/racing/application/use-cases/GetTotalDriversUseCase'; +import { CompleteDriverOnboardingUseCase } from '../../../../core/racing/application/use-cases/CompleteDriverOnboardingUseCase'; +import { IsDriverRegisteredForRaceUseCase } from '../../../../core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase'; + +// Presenters +import { DriversLeaderboardPresenter } from './presenters/DriversLeaderboardPresenter'; +import { DriverStatsPresenter } from './presenters/DriverStatsPresenter'; +import { CompleteOnboardingPresenter } from './presenters/CompleteOnboardingPresenter'; +import { DriverRegistrationStatusPresenter } from './presenters/DriverRegistrationStatusPresenter'; + +// Tokens +import { GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN, GET_TOTAL_DRIVERS_USE_CASE_TOKEN, COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN, IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN, LOGGER_TOKEN } from './DriverProviders'; +import { Logger } from '../../../../core/shared/logging/Logger'; @Injectable() export class DriverService { - - constructor() {} + constructor( + @Inject(GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN) private readonly getDriversLeaderboardUseCase: GetDriversLeaderboardUseCase, + @Inject(GET_TOTAL_DRIVERS_USE_CASE_TOKEN) private readonly getTotalDriversUseCase: GetTotalDriversUseCase, + @Inject(COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN) private readonly completeDriverOnboardingUseCase: CompleteDriverOnboardingUseCase, + @Inject(IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN) private readonly isDriverRegisteredForRaceUseCase: IsDriverRegisteredForRaceUseCase, + @Inject(LOGGER_TOKEN) private readonly logger: Logger, + ) {} async getDriversLeaderboard(): Promise { - console.log('[DriverService] Returning mock driver leaderboard.'); - const drivers: DriverLeaderboardItemViewModel[] = [ - { id: 'driver-1', name: 'Mock Driver 1', rating: 2500, skillLevel: 'Pro', nationality: 'DE', racesCompleted: 50, wins: 10, podiums: 20, isActive: true, rank: 1, avatarUrl: 'https://cdn.example.com/avatars/driver-1.png' }, - { id: 'driver-2', name: 'Mock Driver 2', rating: 2400, skillLevel: 'Amateur', nationality: 'US', racesCompleted: 40, wins: 5, podiums: 15, isActive: true, rank: 2, avatarUrl: 'https://cdn.example.com/avatars/driver-2.png' }, - ]; - return { - drivers: drivers.sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0)), - totalRaces: drivers.reduce((sum, item) => sum + (item.racesCompleted ?? 0), 0), - totalWins: drivers.reduce((sum, item) => sum + (item.wins ?? 0), 0), - activeCount: drivers.filter(d => d.isActive).length, - }; + this.logger.debug('[DriverService] Fetching drivers leaderboard.'); + + const presenter = new DriversLeaderboardPresenter(); + await this.getDriversLeaderboardUseCase.execute(undefined, presenter); + return presenter.viewModel; } async getTotalDrivers(): Promise { - console.log('[DriverService] Returning mock total drivers.'); - return { - totalDrivers: 2, - }; + this.logger.debug('[DriverService] Fetching total drivers count.'); + + const presenter = new DriverStatsPresenter(); + await this.getTotalDriversUseCase.execute(undefined, presenter); + return presenter.viewModel; } async completeOnboarding(userId: string, input: CompleteOnboardingInput): Promise { - console.log('Completing onboarding for user:', userId, input); - return { - success: true, - driverId: `driver-${userId}-onboarded`, - }; + this.logger.debug('Completing onboarding for user:', userId); + + const presenter = new CompleteOnboardingPresenter(); + await this.completeDriverOnboardingUseCase.execute({ + userId, + firstName: input.firstName, + lastName: input.lastName, + displayName: input.displayName, + country: input.country, + timezone: input.timezone, + bio: input.bio, + }, presenter); + return presenter.viewModel; } async getDriverRegistrationStatus(query: GetDriverRegistrationStatusQuery): Promise { - console.log('Checking driver registration status:', query); - return { - isRegistered: false, // Mock response - raceId: query.raceId, - driverId: query.driverId, - }; + this.logger.debug('Checking driver registration status:', query); + + const presenter = new DriverRegistrationStatusPresenter(); + await this.isDriverRegisteredForRaceUseCase.execute({ raceId: query.raceId, driverId: query.driverId }, presenter); + return presenter.viewModel; } } diff --git a/apps/api/src/modules/driver/presenters/CompleteOnboardingPresenter.spec.ts b/apps/api/src/modules/driver/presenters/CompleteOnboardingPresenter.spec.ts new file mode 100644 index 000000000..8a78f8b66 --- /dev/null +++ b/apps/api/src/modules/driver/presenters/CompleteOnboardingPresenter.spec.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { CompleteOnboardingPresenter } from './CompleteOnboardingPresenter'; +import type { CompleteDriverOnboardingResultDTO } from '../../../../../core/racing/application/presenters/ICompleteDriverOnboardingPresenter'; + +describe('CompleteOnboardingPresenter', () => { + let presenter: CompleteOnboardingPresenter; + + beforeEach(() => { + presenter = new CompleteOnboardingPresenter(); + }); + + describe('present', () => { + it('should map successful core DTO to API view model', () => { + const dto: CompleteDriverOnboardingResultDTO = { + success: true, + driverId: 'driver-123', + }; + + presenter.present(dto); + + const result = presenter.viewModel; + + expect(result).toEqual({ + success: true, + driverId: 'driver-123', + errorMessage: undefined, + }); + }); + + it('should map failed core DTO to API view model', () => { + const dto: CompleteDriverOnboardingResultDTO = { + success: false, + errorMessage: 'Driver already exists', + }; + + presenter.present(dto); + + const result = presenter.viewModel; + + expect(result).toEqual({ + success: false, + driverId: undefined, + errorMessage: 'Driver already exists', + }); + }); + }); + + describe('reset', () => { + it('should reset the result', () => { + const dto: CompleteDriverOnboardingResultDTO = { + success: true, + driverId: 'driver-123', + }; + + presenter.present(dto); + expect(presenter.viewModel).toBeDefined(); + + presenter.reset(); + expect(() => presenter.viewModel).toThrow('Presenter not presented'); + }); + }); +}); \ No newline at end of file diff --git a/apps/api/src/modules/driver/presenters/CompleteOnboardingPresenter.ts b/apps/api/src/modules/driver/presenters/CompleteOnboardingPresenter.ts new file mode 100644 index 000000000..7c862af2a --- /dev/null +++ b/apps/api/src/modules/driver/presenters/CompleteOnboardingPresenter.ts @@ -0,0 +1,23 @@ +import { CompleteOnboardingOutput } from '../dto/DriverDto'; +import type { ICompleteDriverOnboardingPresenter, CompleteDriverOnboardingResultDTO } from '../../../../../core/racing/application/presenters/ICompleteDriverOnboardingPresenter'; + +export class CompleteOnboardingPresenter implements ICompleteDriverOnboardingPresenter { + private result: CompleteOnboardingOutput | null = null; + + reset() { + this.result = null; + } + + present(dto: CompleteDriverOnboardingResultDTO) { + this.result = { + success: dto.success, + driverId: dto.driverId, + errorMessage: dto.errorMessage, + }; + } + + get viewModel(): CompleteOnboardingOutput { + 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/driver/presenters/DriverRegistrationStatusPresenter.spec.ts b/apps/api/src/modules/driver/presenters/DriverRegistrationStatusPresenter.spec.ts new file mode 100644 index 000000000..bd7f81608 --- /dev/null +++ b/apps/api/src/modules/driver/presenters/DriverRegistrationStatusPresenter.spec.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { DriverRegistrationStatusPresenter } from './DriverRegistrationStatusPresenter'; + +describe('DriverRegistrationStatusPresenter', () => { + let presenter: DriverRegistrationStatusPresenter; + + beforeEach(() => { + presenter = new DriverRegistrationStatusPresenter(); + }); + + describe('present', () => { + it('should map parameters to view model for registered driver', () => { + presenter.present(true, 'race-123', 'driver-456'); + + const result = presenter.viewModel; + + expect(result).toEqual({ + isRegistered: true, + raceId: 'race-123', + driverId: 'driver-456', + }); + }); + + it('should map parameters to view model for unregistered driver', () => { + presenter.present(false, 'race-789', 'driver-101'); + + const result = presenter.viewModel; + + expect(result).toEqual({ + isRegistered: false, + raceId: 'race-789', + driverId: 'driver-101', + }); + }); + }); + + describe('reset', () => { + it('should reset the result', () => { + presenter.present(true, 'race-123', 'driver-456'); + expect(presenter.viewModel).toBeDefined(); + + presenter.reset(); + expect(() => presenter.viewModel).toThrow('Presenter not presented'); + }); + }); +}); \ No newline at end of file diff --git a/apps/api/src/modules/driver/presenters/DriverRegistrationStatusPresenter.ts b/apps/api/src/modules/driver/presenters/DriverRegistrationStatusPresenter.ts new file mode 100644 index 000000000..2c1d5abf3 --- /dev/null +++ b/apps/api/src/modules/driver/presenters/DriverRegistrationStatusPresenter.ts @@ -0,0 +1,28 @@ +import { DriverRegistrationStatusViewModel } from '../dto/DriverDto'; +import type { IDriverRegistrationStatusPresenter } from '../../../../../core/racing/application/presenters/IDriverRegistrationStatusPresenter'; + +export class DriverRegistrationStatusPresenter implements IDriverRegistrationStatusPresenter { + private result: DriverRegistrationStatusViewModel | null = null; + + present(isRegistered: boolean, raceId: string, driverId: string) { + this.result = { + isRegistered, + raceId, + driverId, + }; + } + + getViewModel(): DriverRegistrationStatusViewModel { + if (!this.result) throw new Error('Presenter not presented'); + return this.result; + } + + // For consistency with other presenters + reset() { + this.result = null; + } + + get viewModel(): DriverRegistrationStatusViewModel { + return this.getViewModel(); + } +} \ No newline at end of file diff --git a/apps/api/src/modules/driver/presenters/DriverStatsPresenter.spec.ts b/apps/api/src/modules/driver/presenters/DriverStatsPresenter.spec.ts new file mode 100644 index 000000000..500c9bc98 --- /dev/null +++ b/apps/api/src/modules/driver/presenters/DriverStatsPresenter.spec.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { DriverStatsPresenter } from './DriverStatsPresenter'; +import type { TotalDriversResultDTO } from '../../../../../core/racing/application/presenters/ITotalDriversPresenter'; + +describe('DriverStatsPresenter', () => { + let presenter: DriverStatsPresenter; + + beforeEach(() => { + presenter = new DriverStatsPresenter(); + }); + + describe('present', () => { + it('should map core DTO to API view model correctly', () => { + const dto: TotalDriversResultDTO = { + totalDrivers: 42, + }; + + presenter.present(dto); + + const result = presenter.viewModel; + + expect(result).toEqual({ + totalDrivers: 42, + }); + }); + }); + + describe('reset', () => { + it('should reset the result', () => { + const dto: TotalDriversResultDTO = { + totalDrivers: 10, + }; + + presenter.present(dto); + expect(presenter.viewModel).toBeDefined(); + + presenter.reset(); + expect(() => presenter.viewModel).toThrow('Presenter not presented'); + }); + }); +}); \ No newline at end of file diff --git a/apps/api/src/modules/driver/presenters/DriverStatsPresenter.ts b/apps/api/src/modules/driver/presenters/DriverStatsPresenter.ts new file mode 100644 index 000000000..3057ec178 --- /dev/null +++ b/apps/api/src/modules/driver/presenters/DriverStatsPresenter.ts @@ -0,0 +1,21 @@ +import { DriverStatsDto } from '../dto/DriverDto'; +import type { ITotalDriversPresenter, TotalDriversResultDTO } from '../../../../../core/racing/application/presenters/ITotalDriversPresenter'; + +export class DriverStatsPresenter implements ITotalDriversPresenter { + private result: DriverStatsDto | null = null; + + reset() { + this.result = null; + } + + present(dto: TotalDriversResultDTO) { + this.result = { + totalDrivers: dto.totalDrivers, + }; + } + + get viewModel(): DriverStatsDto { + 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/driver/presenters/DriversLeaderboardPresenter.spec.ts b/apps/api/src/modules/driver/presenters/DriversLeaderboardPresenter.spec.ts new file mode 100644 index 000000000..6f89f07aa --- /dev/null +++ b/apps/api/src/modules/driver/presenters/DriversLeaderboardPresenter.spec.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { DriversLeaderboardPresenter } from './DriversLeaderboardPresenter'; +import type { DriversLeaderboardResultDTO } from '../../../../../core/racing/application/presenters/IDriversLeaderboardPresenter'; + +describe('DriversLeaderboardPresenter', () => { + let presenter: DriversLeaderboardPresenter; + + beforeEach(() => { + presenter = new DriversLeaderboardPresenter(); + }); + + describe('present', () => { + it('should map core DTO to API view model correctly', () => { + const dto: DriversLeaderboardResultDTO = { + drivers: [ + { + id: 'driver-1', + name: 'Driver One', + country: 'US', + iracingId: '12345', + joinedAt: new Date('2023-01-01'), + }, + { + id: 'driver-2', + name: 'Driver Two', + country: 'DE', + iracingId: '67890', + joinedAt: new Date('2023-01-02'), + }, + ], + rankings: [ + { driverId: 'driver-1', rating: 2500, overallRank: 1 }, + { driverId: 'driver-2', rating: 2400, overallRank: 2 }, + ], + stats: { + 'driver-1': { racesCompleted: 50, wins: 10, podiums: 20 }, + 'driver-2': { racesCompleted: 40, wins: 5, podiums: 15 }, + }, + avatarUrls: { + 'driver-1': 'https://example.com/avatar1.png', + 'driver-2': 'https://example.com/avatar2.png', + }, + }; + + presenter.present(dto); + + const result = presenter.viewModel; + + expect(result.drivers).toHaveLength(2); + expect(result.drivers[0]).toEqual({ + id: 'driver-1', + name: 'Driver One', + rating: 2500, + skillLevel: 'Pro', + nationality: 'US', + racesCompleted: 50, + wins: 10, + podiums: 20, + isActive: true, + rank: 1, + avatarUrl: 'https://example.com/avatar1.png', + }); + expect(result.drivers[1]).toEqual({ + id: 'driver-2', + name: 'Driver Two', + rating: 2400, + skillLevel: 'Pro', + nationality: 'DE', + racesCompleted: 40, + wins: 5, + podiums: 15, + isActive: true, + rank: 2, + avatarUrl: 'https://example.com/avatar2.png', + }); + expect(result.totalRaces).toBe(90); + expect(result.totalWins).toBe(15); + expect(result.activeCount).toBe(2); + }); + + it('should sort drivers by rating descending', () => { + const dto: DriversLeaderboardResultDTO = { + drivers: [ + { + id: 'driver-1', + name: 'Driver One', + country: 'US', + iracingId: '12345', + joinedAt: new Date(), + }, + { + id: 'driver-2', + name: 'Driver Two', + country: 'DE', + iracingId: '67890', + joinedAt: new Date(), + }, + ], + rankings: [ + { driverId: 'driver-1', rating: 2400, overallRank: 2 }, + { driverId: 'driver-2', rating: 2500, overallRank: 1 }, + ], + stats: {}, + avatarUrls: {}, + }; + + presenter.present(dto); + + const result = presenter.viewModel; + + expect(result.drivers[0].id).toBe('driver-2'); // Higher rating first + expect(result.drivers[1].id).toBe('driver-1'); + }); + + it('should handle missing stats gracefully', () => { + const dto: DriversLeaderboardResultDTO = { + drivers: [ + { + id: 'driver-1', + name: 'Driver One', + country: 'US', + iracingId: '12345', + joinedAt: new Date(), + }, + ], + rankings: [ + { driverId: 'driver-1', rating: 2500, overallRank: 1 }, + ], + stats: {}, // No stats + avatarUrls: {}, + }; + + presenter.present(dto); + + const result = presenter.viewModel; + + expect(result.drivers[0].racesCompleted).toBe(0); + expect(result.drivers[0].wins).toBe(0); + expect(result.drivers[0].podiums).toBe(0); + }); + }); + + describe('reset', () => { + it('should reset the result', () => { + const dto: DriversLeaderboardResultDTO = { + drivers: [], + rankings: [], + stats: {}, + avatarUrls: {}, + }; + + presenter.present(dto); + expect(presenter.viewModel).toBeDefined(); + + presenter.reset(); + expect(() => presenter.viewModel).toThrow('Presenter not presented'); + }); + }); +}); \ No newline at end of file diff --git a/apps/api/src/modules/driver/presenters/DriversLeaderboardPresenter.ts b/apps/api/src/modules/driver/presenters/DriversLeaderboardPresenter.ts new file mode 100644 index 000000000..2aed681fe --- /dev/null +++ b/apps/api/src/modules/driver/presenters/DriversLeaderboardPresenter.ts @@ -0,0 +1,49 @@ +import { DriversLeaderboardViewModel, DriverLeaderboardItemViewModel } from '../dto/DriverDto'; +import type { IDriversLeaderboardPresenter, DriversLeaderboardResultDTO } from '../../../../../core/racing/application/presenters/IDriversLeaderboardPresenter'; + +export class DriversLeaderboardPresenter implements IDriversLeaderboardPresenter { + private result: DriversLeaderboardViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: DriversLeaderboardResultDTO) { + const drivers: DriverLeaderboardItemViewModel[] = dto.drivers.map(driver => { + const ranking = dto.rankings.find(r => r.driverId === driver.id); + const stats = dto.stats[driver.id]; + const avatarUrl = dto.avatarUrls[driver.id]; + + return { + id: driver.id, + name: driver.name, + rating: ranking?.rating ?? 0, + skillLevel: 'Pro', // TODO: map from domain + nationality: driver.country, + racesCompleted: stats?.racesCompleted ?? 0, + wins: stats?.wins ?? 0, + podiums: stats?.podiums ?? 0, + isActive: true, // TODO: determine from domain + rank: ranking?.overallRank ?? 0, + avatarUrl, + }; + }); + + // Calculate totals + const totalRaces = drivers.reduce((sum, d) => sum + (d.racesCompleted ?? 0), 0); + const totalWins = drivers.reduce((sum, d) => sum + (d.wins ?? 0), 0); + const activeCount = drivers.filter(d => d.isActive).length; + + this.result = { + drivers: drivers.sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0)), + totalRaces, + totalWins, + activeCount, + }; + } + + get viewModel(): DriversLeaderboardViewModel { + 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/league/LeagueController.spec.ts b/apps/api/src/modules/league/LeagueController.spec.ts new file mode 100644 index 000000000..7af9fb510 --- /dev/null +++ b/apps/api/src/modules/league/LeagueController.spec.ts @@ -0,0 +1,43 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { LeagueController } from './LeagueController'; +import { LeagueService } from './LeagueService'; +import { LeagueProviders } from './LeagueProviders'; + +describe('LeagueController (integration)', () => { + let controller: LeagueController; + let service: LeagueService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [LeagueController], + providers: [LeagueService, ...LeagueProviders], + }).compile(); + + controller = module.get(LeagueController); + service = module.get(LeagueService); + }); + + it('should get total leagues', async () => { + const result = await controller.getTotalLeagues(); + expect(result).toHaveProperty('totalLeagues'); + expect(typeof result.totalLeagues).toBe('number'); + }); + + it('should get all leagues with capacity', async () => { + const result = await controller.getAllLeaguesWithCapacity(); + expect(result).toHaveProperty('leagues'); + expect(result).toHaveProperty('totalCount'); + expect(Array.isArray(result.leagues)).toBe(true); + }); + + it('should get league standings', async () => { + try { + const result = await controller.getLeagueStandings('non-existent-league'); + expect(result).toHaveProperty('standings'); + expect(Array.isArray(result.standings)).toBe(true); + } catch (error) { + // Expected for non-existent league + expect(error.message).toContain('not found'); + } + }); +}); \ No newline at end of file diff --git a/apps/api/src/modules/league/LeagueController.ts b/apps/api/src/modules/league/LeagueController.ts index 6bee0e41d..b7eb3ba12 100644 --- a/apps/api/src/modules/league/LeagueController.ts +++ b/apps/api/src/modules/league/LeagueController.ts @@ -1,7 +1,7 @@ import { Controller, Get, Post, Patch, Body, Param } from '@nestjs/common'; import { ApiTags, ApiResponse, ApiOperation, ApiBody } from '@nestjs/swagger'; import { LeagueService } from './LeagueService'; -import { AllLeaguesWithCapacityViewModel, LeagueStatsDto, LeagueJoinRequestViewModel, ApproveJoinRequestInput, ApproveJoinRequestOutput, RejectJoinRequestInput, RejectJoinRequestOutput, LeagueAdminPermissionsViewModel, RemoveLeagueMemberInput, RemoveLeagueMemberOutput, UpdateLeagueMemberRoleInput, UpdateLeagueMemberRoleOutput, LeagueOwnerSummaryViewModel, LeagueConfigFormModelDto, LeagueAdminProtestsViewModel, LeagueSeasonSummaryViewModel } from './dto/LeagueDto'; +import { AllLeaguesWithCapacityViewModel, LeagueStatsDto, LeagueJoinRequestViewModel, ApproveJoinRequestInput, ApproveJoinRequestOutput, RejectJoinRequestInput, RejectJoinRequestOutput, LeagueAdminPermissionsViewModel, RemoveLeagueMemberInput, RemoveLeagueMemberOutput, UpdateLeagueMemberRoleInput, UpdateLeagueMemberRoleOutput, LeagueOwnerSummaryViewModel, LeagueConfigFormModelDto, LeagueAdminProtestsViewModel, LeagueSeasonSummaryViewModel, LeagueMembershipsViewModel, LeagueStandingsViewModel, LeagueScheduleViewModel, LeagueStatsViewModel, LeagueAdminViewModel, CreateLeagueInput, CreateLeagueOutput } from './dto/LeagueDto'; import { GetLeagueAdminPermissionsInput, GetLeagueJoinRequestsQuery, GetLeagueProtestsQuery, GetLeagueSeasonsQuery, GetLeagueAdminConfigQuery, GetLeagueOwnerSummaryQuery } from './dto/LeagueDto'; // Explicitly import queries @ApiTags('leagues') @@ -133,4 +133,47 @@ export class LeagueController { const query: GetLeagueSeasonsQuery = { leagueId }; return this.leagueService.getLeagueSeasons(query); } + + @Get(':leagueId/memberships') + @ApiOperation({ summary: 'Get league memberships' }) + @ApiResponse({ status: 200, description: 'List of league members', type: LeagueMembershipsViewModel }) + async getLeagueMemberships(@Param('leagueId') leagueId: string): Promise { + return this.leagueService.getLeagueMemberships(leagueId); + } + + @Get(':leagueId/standings') + @ApiOperation({ summary: 'Get league standings' }) + @ApiResponse({ status: 200, description: 'League standings', type: LeagueStandingsViewModel }) + async getLeagueStandings(@Param('leagueId') leagueId: string): Promise { + return this.leagueService.getLeagueStandings(leagueId); + } + + @Get(':leagueId/schedule') + @ApiOperation({ summary: 'Get league schedule' }) + @ApiResponse({ status: 200, description: 'League schedule', type: LeagueScheduleViewModel }) + async getLeagueSchedule(@Param('leagueId') leagueId: string): Promise { + return this.leagueService.getLeagueSchedule(leagueId); + } + + @Get(':leagueId/stats') + @ApiOperation({ summary: 'Get league stats' }) + @ApiResponse({ status: 200, description: 'League stats', type: LeagueStatsViewModel }) + async getLeagueStats(@Param('leagueId') leagueId: string): Promise { + return this.leagueService.getLeagueStats(leagueId); + } + + @Get(':leagueId/admin') + @ApiOperation({ summary: 'Get league admin data' }) + @ApiResponse({ status: 200, description: 'League admin data', type: LeagueAdminViewModel }) + async getLeagueAdmin(@Param('leagueId') leagueId: string): Promise { + return this.leagueService.getLeagueAdmin(leagueId); + } + + @Post() + @ApiOperation({ summary: 'Create a new league' }) + @ApiBody({ type: CreateLeagueInput }) + @ApiResponse({ status: 201, description: 'League created successfully', type: CreateLeagueOutput }) + async createLeague(@Body() input: CreateLeagueInput): Promise { + return this.leagueService.createLeague(input); + } } diff --git a/apps/api/src/modules/league/LeagueModule.ts b/apps/api/src/modules/league/LeagueModule.ts index f51003307..7c9520e8c 100644 --- a/apps/api/src/modules/league/LeagueModule.ts +++ b/apps/api/src/modules/league/LeagueModule.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { LeagueService } from './LeagueService'; import { LeagueController } from './LeagueController'; +import { LeagueProviders } from './LeagueProviders'; @Module({ controllers: [LeagueController], - providers: [LeagueService], + providers: LeagueProviders, exports: [LeagueService], }) export class LeagueModule {} diff --git a/apps/api/src/modules/league/LeagueProviders.ts b/apps/api/src/modules/league/LeagueProviders.ts index 52e95cfea..99385ac54 100644 --- a/apps/api/src/modules/league/LeagueProviders.ts +++ b/apps/api/src/modules/league/LeagueProviders.ts @@ -2,15 +2,7 @@ import { Provider } from '@nestjs/common'; import { LeagueService } from './LeagueService'; // Import core interfaces -import { ILeagueRepository } from 'core/racing/domain/repositories/ILeagueRepository'; -import { ILeagueMembershipRepository } from 'core/racing/domain/repositories/ILeagueMembershipRepository'; -import { ILeagueStandingsRepository } from 'core/league/application/ports/ILeagueStandingsRepository'; -import { ISeasonRepository } from 'core/racing/domain/repositories/ISeasonRepository'; -import { ILeagueScoringConfigRepository } from 'core/racing/domain/repositories/ILeagueScoringConfigRepository'; -import { IGameRepository } from 'core/racing/domain/repositories/IGameRepository'; -import { IProtestRepository } from 'core/racing/domain/repositories/IProtestRepository'; -import { IRaceRepository } from 'core/racing/domain/repositories/IRaceRepository'; -import { Logger } from 'core/shared/logging/Logger'; +import { Logger } from '@gridpilot/shared/application/Logger'; // Import concrete in-memory implementations import { InMemoryLeagueRepository } from 'adapters/racing/persistence/inmemory/InMemoryLeagueRepository'; @@ -21,17 +13,41 @@ import { InMemoryLeagueScoringConfigRepository } from 'adapters/racing/persisten import { InMemoryGameRepository } from 'adapters/racing/persistence/inmemory/InMemoryGameRepository'; import { InMemoryProtestRepository } from 'adapters/racing/persistence/inmemory/InMemoryProtestRepository'; import { InMemoryRaceRepository } from 'adapters/racing/persistence/inmemory/InMemoryRaceRepository'; +import { InMemoryDriverRepository } from 'adapters/racing/persistence/inmemory/InMemoryDriverRepository'; +import { InMemoryStandingRepository } from 'adapters/racing/persistence/inmemory/InMemoryStandingRepository'; import { ConsoleLogger } from 'adapters/logging/ConsoleLogger'; +// Import use cases +import { GetAllLeaguesWithCapacityUseCase } from '@gridpilot/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase'; +import { GetLeagueStandingsUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueStandingsUseCase'; +import { GetLeagueFullConfigUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueFullConfigUseCase'; +import { CreateLeagueWithSeasonAndScoringUseCase } from '@gridpilot/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase'; +import { GetRaceProtestsUseCase } from '@gridpilot/racing/application/use-cases/GetRaceProtestsUseCase'; +import { GetTotalLeaguesUseCase } from '@gridpilot/racing/application/use-cases/GetTotalLeaguesUseCase'; +import { GetLeagueJoinRequestsUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueJoinRequestsUseCase'; +import { ApproveLeagueJoinRequestUseCase } from '@gridpilot/racing/application/use-cases/ApproveLeagueJoinRequestUseCase'; +import { RejectLeagueJoinRequestUseCase } from '@gridpilot/racing/application/use-cases/RejectLeagueJoinRequestUseCase'; +import { RemoveLeagueMemberUseCase } from '@gridpilot/racing/application/use-cases/RemoveLeagueMemberUseCase'; +import { UpdateLeagueMemberRoleUseCase } from '@gridpilot/racing/application/use-cases/UpdateLeagueMemberRoleUseCase'; +import { GetLeagueOwnerSummaryUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueOwnerSummaryUseCase'; +import { GetLeagueProtestsUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueProtestsUseCase'; +import { GetLeagueSeasonsUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueSeasonsUseCase'; +import { GetLeagueMembershipsUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueMembershipsUseCase'; +import { GetLeagueScheduleUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueScheduleUseCase'; +import { GetLeagueStatsUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueStatsUseCase'; +import { GetLeagueAdminPermissionsUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueAdminPermissionsUseCase'; + // Define injection tokens export const LEAGUE_REPOSITORY_TOKEN = 'ILeagueRepository'; export const LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN = 'ILeagueMembershipRepository'; export const LEAGUE_STANDINGS_REPOSITORY_TOKEN = 'ILeagueStandingsRepository'; +export const STANDING_REPOSITORY_TOKEN = 'IStandingRepository'; export const SEASON_REPOSITORY_TOKEN = 'ISeasonRepository'; export const LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN = 'ILeagueScoringConfigRepository'; export const GAME_REPOSITORY_TOKEN = 'IGameRepository'; export const PROTEST_REPOSITORY_TOKEN = 'IProtestRepository'; export const RACE_REPOSITORY_TOKEN = 'IRaceRepository'; +export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository'; export const LOGGER_TOKEN = 'Logger'; // Already defined in AuthProviders, but good to have here too export const LeagueProviders: Provider[] = [ @@ -51,6 +67,11 @@ export const LeagueProviders: Provider[] = [ useFactory: (logger: Logger) => new InMemoryLeagueStandingsRepository(logger), // Factory for InMemoryLeagueStandingsRepository inject: [LOGGER_TOKEN], }, + { + provide: STANDING_REPOSITORY_TOKEN, + useFactory: (logger: Logger) => new InMemoryStandingRepository(logger), // Factory for InMemoryStandingRepository + inject: [LOGGER_TOKEN], + }, { provide: SEASON_REPOSITORY_TOKEN, useFactory: (logger: Logger) => new InMemorySeasonRepository(logger), // Factory for InMemorySeasonRepository @@ -76,8 +97,33 @@ export const LeagueProviders: Provider[] = [ useFactory: (logger: Logger) => new InMemoryRaceRepository(logger), inject: [LOGGER_TOKEN], }, + { + provide: DRIVER_REPOSITORY_TOKEN, + useFactory: (logger: Logger) => new InMemoryDriverRepository(logger), + inject: [LOGGER_TOKEN], + }, { provide: LOGGER_TOKEN, useClass: ConsoleLogger, }, + // Use cases + GetAllLeaguesWithCapacityUseCase, + GetLeagueStandingsUseCase, + GetLeagueStatsUseCase, + GetLeagueFullConfigUseCase, + CreateLeagueWithSeasonAndScoringUseCase, + GetRaceProtestsUseCase, + GetTotalLeaguesUseCase, + GetLeagueJoinRequestsUseCase, + ApproveLeagueJoinRequestUseCase, + RejectLeagueJoinRequestUseCase, + RemoveLeagueMemberUseCase, + UpdateLeagueMemberRoleUseCase, + GetLeagueOwnerSummaryUseCase, + GetLeagueProtestsUseCase, + GetLeagueSeasonsUseCase, + GetLeagueMembershipsUseCase, + GetLeagueScheduleUseCase, + GetLeagueStatsUseCase, + GetLeagueAdminPermissionsUseCase, ]; diff --git a/apps/api/src/modules/league/LeagueService.spec.ts b/apps/api/src/modules/league/LeagueService.spec.ts new file mode 100644 index 000000000..98300edf8 --- /dev/null +++ b/apps/api/src/modules/league/LeagueService.spec.ts @@ -0,0 +1,170 @@ +import { LeagueService } from './LeagueService'; +import { GetAllLeaguesWithCapacityUseCase } from '@gridpilot/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase'; +import { GetLeagueStandingsUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueStandingsUseCase'; +import { GetLeagueStatsUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueStatsUseCase'; +import { GetLeagueFullConfigUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueFullConfigUseCase'; +import { CreateLeagueWithSeasonAndScoringUseCase } from '@gridpilot/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase'; +import { GetRaceProtestsUseCase } from '@gridpilot/racing/application/use-cases/GetRaceProtestsUseCase'; +import { GetTotalLeaguesUseCase } from '@gridpilot/racing/application/use-cases/GetTotalLeaguesUseCase'; +import { GetLeagueJoinRequestsUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueJoinRequestsUseCase'; +import { ApproveLeagueJoinRequestUseCase } from '@gridpilot/racing/application/use-cases/ApproveLeagueJoinRequestUseCase'; +import { RejectLeagueJoinRequestUseCase } from '@gridpilot/racing/application/use-cases/RejectLeagueJoinRequestUseCase'; +import { RemoveLeagueMemberUseCase } from '@gridpilot/racing/application/use-cases/RemoveLeagueMemberUseCase'; +import { UpdateLeagueMemberRoleUseCase } from '@gridpilot/racing/application/use-cases/UpdateLeagueMemberRoleUseCase'; +import { GetLeagueOwnerSummaryUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueOwnerSummaryUseCase'; +import { GetLeagueProtestsUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueProtestsUseCase'; +import { Logger } from '@gridpilot/shared/application/Logger'; + +describe('LeagueService', () => { + let service: LeagueService; + let mockGetTotalLeaguesUseCase: jest.Mocked; + let mockGetLeagueJoinRequestsUseCase: jest.Mocked; + let mockApproveLeagueJoinRequestUseCase: jest.Mocked; + let mockLogger: jest.Mocked; + + beforeEach(() => { + mockGetTotalLeaguesUseCase = { + execute: jest.fn(), + } as any; + mockGetLeagueJoinRequestsUseCase = { + execute: jest.fn(), + } as any; + mockApproveLeagueJoinRequestUseCase = { + execute: jest.fn(), + } as any; + mockLogger = { + debug: jest.fn(), + } as any; + + service = new LeagueService( + {} as any, // mockGetAllLeaguesWithCapacityUseCase + {} as any, // mockGetLeagueStandingsUseCase + {} as any, // mockGetLeagueStatsUseCase + {} as any, // mockGetLeagueFullConfigUseCase + {} as any, // mockCreateLeagueWithSeasonAndScoringUseCase + {} as any, // mockGetRaceProtestsUseCase + mockGetTotalLeaguesUseCase, + mockGetLeagueJoinRequestsUseCase, + mockApproveLeagueJoinRequestUseCase, + {} as any, // mockRejectLeagueJoinRequestUseCase + {} as any, // mockRemoveLeagueMemberUseCase + {} as any, // mockUpdateLeagueMemberRoleUseCase + {} as any, // mockGetLeagueOwnerSummaryUseCase + {} as any, // mockGetLeagueProtestsUseCase + {} as any, // mockGetLeagueSeasonsUseCase + {} as any, // mockGetLeagueMembershipsUseCase + {} as any, // mockGetLeagueScheduleUseCase + {} as any, // mockGetLeagueAdminPermissionsUseCase + mockLogger, + ); + }); + + it('should get total leagues', async () => { + mockGetTotalLeaguesUseCase.execute.mockImplementation(async (params, presenter) => { + presenter.present({ totalLeagues: 5 }); + }); + + const result = await service.getTotalLeagues(); + + expect(result).toEqual({ totalLeagues: 5 }); + expect(mockLogger.debug).toHaveBeenCalledWith('[LeagueService] Fetching total leagues count.'); + }); + + it('should get league join requests', async () => { + mockGetLeagueJoinRequestsUseCase.execute.mockImplementation(async (params, presenter) => { + presenter.present({ + joinRequests: [{ id: 'req-1', leagueId: 'league-1', driverId: 'driver-1', requestedAt: new Date(), message: 'msg' }], + drivers: [{ id: 'driver-1', name: 'Driver 1' }], + }); + }); + + const result = await service.getLeagueJoinRequests('league-1'); + + expect(result).toEqual([{ + id: 'req-1', + leagueId: 'league-1', + driverId: 'driver-1', + requestedAt: expect.any(Date), + message: 'msg', + driver: { id: 'driver-1', name: 'Driver 1' }, + }]); + }); + + it('should approve league join request', async () => { + mockApproveLeagueJoinRequestUseCase.execute.mockImplementation(async (params, presenter) => { + presenter.present({ success: true, message: 'Join request approved.' }); + }); + + const result = await service.approveLeagueJoinRequest({ leagueId: 'league-1', requestId: 'req-1' }); + + expect(result).toEqual({ success: true, message: 'Join request approved.' }); + }); + + it('should reject league join request', async () => { + const mockRejectUseCase = { + execute: jest.fn().mockImplementation(async (params, presenter) => { + presenter.present({ success: true, message: 'Join request rejected.' }); + }), + } as any; + + service = new LeagueService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + mockRejectUseCase, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + mockLogger, + ); + + const result = await service.rejectLeagueJoinRequest({ requestId: 'req-1', leagueId: 'league-1' }); + + expect(result).toEqual({ success: true, message: 'Join request rejected.' }); + }); + + it('should remove league member', async () => { + const mockRemoveUseCase = { + execute: jest.fn().mockImplementation(async (params, presenter) => { + presenter.present({ success: true }); + }), + } as any; + + service = new LeagueService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + mockRemoveUseCase, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + mockLogger, + ); + + const result = await service.removeLeagueMember({ leagueId: 'league-1', performerDriverId: 'performer-1', targetDriverId: 'driver-1' }); + + expect(result).toEqual({ success: true }); + }); +}); \ No newline at end of file diff --git a/apps/api/src/modules/league/LeagueService.ts b/apps/api/src/modules/league/LeagueService.ts index 961759060..e773b6b63 100644 --- a/apps/api/src/modules/league/LeagueService.ts +++ b/apps/api/src/modules/league/LeagueService.ts @@ -1,125 +1,237 @@ -import { Injectable } from '@nestjs/common'; -import { AllLeaguesWithCapacityViewModel, LeagueStatsDto, LeagueJoinRequestViewModel, ApproveJoinRequestInput, ApproveJoinRequestOutput, RejectJoinRequestInput, RejectJoinRequestOutput, LeagueAdminPermissionsViewModel, RemoveLeagueMemberInput, RemoveLeagueMemberOutput, UpdateLeagueMemberRoleInput, UpdateLeagueMemberRoleOutput, LeagueOwnerSummaryViewModel, LeagueConfigFormModelDto, LeagueAdminProtestsViewModel, LeagueSeasonSummaryViewModel, GetLeagueAdminPermissionsInput, GetLeagueJoinRequestsQuery, GetLeagueProtestsQuery, GetLeagueSeasonsQuery, GetLeagueAdminConfigQuery, GetLeagueOwnerSummaryQuery } from './dto/LeagueDto'; -import { DriverDto } from '../driver/dto/DriverDto'; // Using the local DTO for mock data -import { RaceDto } from '../race/dto/RaceDto'; // Using the local DTO for mock data +import { Injectable, Inject } from '@nestjs/common'; +import { AllLeaguesWithCapacityViewModel, LeagueStatsDto, LeagueJoinRequestViewModel, ApproveJoinRequestInput, ApproveJoinRequestOutput, RejectJoinRequestInput, RejectJoinRequestOutput, LeagueAdminPermissionsViewModel, RemoveLeagueMemberInput, RemoveLeagueMemberOutput, UpdateLeagueMemberRoleInput, UpdateLeagueMemberRoleOutput, LeagueOwnerSummaryViewModel, LeagueConfigFormModelDto, LeagueAdminProtestsViewModel, LeagueSeasonSummaryViewModel, GetLeagueAdminPermissionsInput, GetLeagueProtestsQuery, GetLeagueSeasonsQuery, GetLeagueAdminConfigQuery, GetLeagueOwnerSummaryQuery, LeagueMembershipsViewModel, LeagueStandingsViewModel, LeagueScheduleViewModel, LeagueStatsViewModel, LeagueAdminViewModel, CreateLeagueInput, CreateLeagueOutput } from './dto/LeagueDto'; -const mockDriverData: Map = new Map(); -mockDriverData.set('driver-owner-1', { id: 'driver-owner-1', name: 'Owner Driver' }); -mockDriverData.set('driver-1', { id: 'driver-1', name: 'Demo Driver 1' }); -mockDriverData.set('driver-2', { id: 'driver-2', name: 'Demo Driver 2' }); +// Core imports +import { Logger } from '@gridpilot/shared/application/Logger'; -const mockRaceData: Map = new Map(); -mockRaceData.set('race-1', { id: 'race-1', name: 'Test Race 1', date: new Date().toISOString() }); -mockRaceData.set('race-2', { id: 'race-2', name: 'Test Race 2', date: new Date().toISOString() }); +// Use cases +import { GetAllLeaguesWithCapacityUseCase } from '@gridpilot/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase'; +import { GetLeagueStandingsUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueStandingsUseCase'; +import { GetLeagueStatsUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueStatsUseCase'; +import { GetLeagueFullConfigUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueFullConfigUseCase'; +import { CreateLeagueWithSeasonAndScoringUseCase } from '@gridpilot/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase'; +import { GetRaceProtestsUseCase } from '@gridpilot/racing/application/use-cases/GetRaceProtestsUseCase'; +import { GetTotalLeaguesUseCase } from '@gridpilot/racing/application/use-cases/GetTotalLeaguesUseCase'; +import { GetLeagueJoinRequestsUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueJoinRequestsUseCase'; +import { ApproveLeagueJoinRequestUseCase } from '@gridpilot/racing/application/use-cases/ApproveLeagueJoinRequestUseCase'; +import { RejectLeagueJoinRequestUseCase } from '@gridpilot/racing/application/use-cases/RejectLeagueJoinRequestUseCase'; +import { RemoveLeagueMemberUseCase } from '@gridpilot/racing/application/use-cases/RemoveLeagueMemberUseCase'; +import { UpdateLeagueMemberRoleUseCase } from '@gridpilot/racing/application/use-cases/UpdateLeagueMemberRoleUseCase'; +import { GetLeagueOwnerSummaryUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueOwnerSummaryUseCase'; +import { GetLeagueProtestsUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueProtestsUseCase'; +import { GetLeagueSeasonsUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueSeasonsUseCase'; +import { GetLeagueMembershipsUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueMembershipsUseCase'; +import { GetLeagueScheduleUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueScheduleUseCase'; +import { GetLeagueAdminPermissionsUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueAdminPermissionsUseCase'; + +// API Presenters +import { LeagueStandingsPresenter } from './presenters/LeagueStandingsPresenter'; +import { AllLeaguesWithCapacityPresenter } from './presenters/AllLeaguesWithCapacityPresenter'; +import { LeagueJoinRequestsPresenter } from './presenters/LeagueJoinRequestsPresenter'; +import { ApproveLeagueJoinRequestPresenter } from './presenters/ApproveLeagueJoinRequestPresenter'; +import { RejectLeagueJoinRequestPresenter } from './presenters/RejectLeagueJoinRequestPresenter'; +import { RemoveLeagueMemberPresenter } from './presenters/RemoveLeagueMemberPresenter'; +import { UpdateLeagueMemberRolePresenter } from './presenters/UpdateLeagueMemberRolePresenter'; +import { GetLeagueOwnerSummaryPresenter } from './presenters/GetLeagueOwnerSummaryPresenter'; +import { GetLeagueProtestsPresenter } from './presenters/GetLeagueProtestsPresenter'; +import { GetLeagueSeasonsPresenter } from './presenters/GetLeagueSeasonsPresenter'; +import { GetLeagueMembershipsPresenter } from './presenters/GetLeagueMembershipsPresenter'; +import { LeagueSchedulePresenter } from './presenters/LeagueSchedulePresenter'; +import { TotalLeaguesPresenter } from './presenters/TotalLeaguesPresenter'; +import { LeagueConfigPresenter } from './presenters/LeagueConfigPresenter'; +import { LeagueStatsPresenter } from './presenters/LeagueStatsPresenter'; +import { GetLeagueAdminPermissionsPresenter } from './presenters/GetLeagueAdminPermissionsPresenter'; + +// Tokens +import { LOGGER_TOKEN } from './LeagueProviders'; @Injectable() export class LeagueService { - - constructor() {} + constructor( + private readonly getAllLeaguesWithCapacityUseCase: GetAllLeaguesWithCapacityUseCase, + private readonly getLeagueStandingsUseCase: GetLeagueStandingsUseCase, + private readonly getLeagueStatsUseCase: GetLeagueStatsUseCase, + private readonly getLeagueFullConfigUseCase: GetLeagueFullConfigUseCase, + private readonly createLeagueWithSeasonAndScoringUseCase: CreateLeagueWithSeasonAndScoringUseCase, + private readonly getRaceProtestsUseCase: GetRaceProtestsUseCase, + private readonly getTotalLeaguesUseCase: GetTotalLeaguesUseCase, + private readonly getLeagueJoinRequestsUseCase: GetLeagueJoinRequestsUseCase, + private readonly approveLeagueJoinRequestUseCase: ApproveLeagueJoinRequestUseCase, + private readonly rejectLeagueJoinRequestUseCase: RejectLeagueJoinRequestUseCase, + private readonly removeLeagueMemberUseCase: RemoveLeagueMemberUseCase, + private readonly updateLeagueMemberRoleUseCase: UpdateLeagueMemberRoleUseCase, + private readonly getLeagueOwnerSummaryUseCase: GetLeagueOwnerSummaryUseCase, + private readonly getLeagueProtestsUseCase: GetLeagueProtestsUseCase, + private readonly getLeagueSeasonsUseCase: GetLeagueSeasonsUseCase, + private readonly getLeagueMembershipsUseCase: GetLeagueMembershipsUseCase, + private readonly getLeagueScheduleUseCase: GetLeagueScheduleUseCase, + private readonly getLeagueAdminPermissionsUseCase: GetLeagueAdminPermissionsUseCase, + @Inject(LOGGER_TOKEN) private readonly logger: Logger, + ) {} async getAllLeaguesWithCapacity(): Promise { - console.log('[LeagueService] Returning mock leagues with capacity.'); - return { - leagues: [ - { id: 'league-1', name: 'Global Racing', description: 'The premier league', ownerId: 'owner-1', settings: { maxDrivers: 100 }, createdAt: new Date().toISOString(), usedSlots: 50, socialLinks: { discordUrl: 'https://discord.gg/test' } }, - { id: 'league-2', name: 'Amateur Series', description: 'Learn the ropes', ownerId: 'owner-2', settings: { maxDrivers: 50 }, createdAt: new Date().toISOString(), usedSlots: 20 }, - ], - totalCount: 2, - }; + this.logger.debug('[LeagueService] Fetching all leagues with capacity.'); + + const presenter = new AllLeaguesWithCapacityPresenter(); + await this.getAllLeaguesWithCapacityUseCase.execute(undefined, presenter); + return presenter.getViewModel()!; } async getTotalLeagues(): Promise { - console.log('[LeagueService] Returning mock total leagues.'); - return { totalLeagues: 2 }; + this.logger.debug('[LeagueService] Fetching total leagues count.'); + const presenter = new TotalLeaguesPresenter(); + await this.getTotalLeaguesUseCase.execute({}, presenter); + return presenter.getViewModel()!; } async getLeagueJoinRequests(leagueId: string): Promise { - console.log(`[LeagueService] Returning mock join requests for league: ${leagueId}.`); - return [ - { - id: 'join-req-1', - leagueId: 'league-1', - driverId: 'driver-1', - requestedAt: new Date(), - message: 'I want to join!', - driver: mockDriverData.get('driver-1'), - }, - ]; + this.logger.debug(`[LeagueService] Fetching join requests for league: ${leagueId}.`); + const presenter = new LeagueJoinRequestsPresenter(); + await this.getLeagueJoinRequestsUseCase.execute({ leagueId }, presenter); + return presenter.getViewModel()!.joinRequests; } async approveLeagueJoinRequest(input: ApproveJoinRequestInput): Promise { - console.log('Approving join request:', input); - return { success: true, message: 'Join request approved.' }; + this.logger.debug('Approving join request:', input); + const presenter = new ApproveLeagueJoinRequestPresenter(); + await this.approveLeagueJoinRequestUseCase.execute({ leagueId: input.leagueId, requestId: input.requestId }, presenter); + return presenter.getViewModel()!; } async rejectLeagueJoinRequest(input: RejectJoinRequestInput): Promise { - console.log('Rejecting join request:', input); - return { success: true, message: 'Join request rejected.' }; + this.logger.debug('Rejecting join request:', input); + const presenter = new RejectLeagueJoinRequestPresenter(); + await this.rejectLeagueJoinRequestUseCase.execute({ requestId: input.requestId }, presenter); + return presenter.getViewModel()!; } async getLeagueAdminPermissions(query: GetLeagueAdminPermissionsInput): Promise { - console.log('Getting league admin permissions:', query); - return { canRemoveMember: true, canUpdateRoles: true }; + this.logger.debug('Getting league admin permissions', { query }); + const presenter = new GetLeagueAdminPermissionsPresenter(); + await this.getLeagueAdminPermissionsUseCase.execute( + { leagueId: query.leagueId, performerDriverId: query.performerDriverId }, + presenter + ); + return presenter.getViewModel()!; } async removeLeagueMember(input: RemoveLeagueMemberInput): Promise { - console.log('Removing league member:', input.leagueId, input.targetDriverId); - return { success: true }; + this.logger.debug('Removing league member', { leagueId: input.leagueId, targetDriverId: input.targetDriverId }); + const presenter = new RemoveLeagueMemberPresenter(); + await this.removeLeagueMemberUseCase.execute({ leagueId: input.leagueId, targetDriverId: input.targetDriverId }, presenter); + return presenter.getViewModel()!; } async updateLeagueMemberRole(input: UpdateLeagueMemberRoleInput): Promise { - console.log('Updating league member role:', input.leagueId, input.targetDriverId, input.newRole); - return { success: true }; + this.logger.debug('Updating league member role', { leagueId: input.leagueId, targetDriverId: input.targetDriverId, newRole: input.newRole }); + const presenter = new UpdateLeagueMemberRolePresenter(); + await this.updateLeagueMemberRoleUseCase.execute({ leagueId: input.leagueId, targetDriverId: input.targetDriverId, newRole: input.newRole }, presenter); + return presenter.getViewModel()!; } async getLeagueOwnerSummary(query: GetLeagueOwnerSummaryQuery): Promise { - console.log('Getting league owner summary:', query); - return { - driver: mockDriverData.get(query.ownerId)!, - rating: 2000, - rank: 1, - }; + this.logger.debug('Getting league owner summary:', query); + const presenter = new GetLeagueOwnerSummaryPresenter(); + await this.getLeagueOwnerSummaryUseCase.execute({ ownerId: query.ownerId }, presenter); + return presenter.getViewModel()!.summary; } async getLeagueFullConfig(query: GetLeagueAdminConfigQuery): Promise { - console.log('Getting league full config:', query); - return { - leagueId: 'league-1', - basics: { name: 'Demo League', description: 'A demo league', visibility: 'public' }, - structure: { mode: 'solo' }, - championships: [], - scoring: { type: 'standard', points: 10 }, - dropPolicy: { strategy: 'none' }, - timings: { raceDayOfWeek: 'Sunday', raceTimeHour: 20, raceTimeMinute: 0 }, - stewarding: { - decisionMode: 'single_steward', - requireDefense: false, - defenseTimeLimit: 24, - voteTimeLimit: 24, - protestDeadlineHours: 2, - stewardingClosesHours: 24, - notifyAccusedOnProtest: true, - notifyOnVoteRequired: true, - }, - }; + this.logger.debug('Getting league full config', { query }); + + const presenter = new LeagueConfigPresenter(); + try { + await this.getLeagueFullConfigUseCase.execute({ leagueId: query.leagueId }, presenter); + return presenter.viewModel; + } catch (error) { + this.logger.error('Error getting league full config', error); + return null; + } } async getLeagueProtests(query: GetLeagueProtestsQuery): Promise { - console.log('Getting league protests:', query); - return { - protests: [ - { id: 'protest-1', raceId: 'race-1', protestingDriverId: 'driver-1', accusedDriverId: 'driver-2', submittedAt: new Date(), description: 'Bad driving!', status: 'pending' }, - ], - racesById: { 'race-1': mockRaceData.get('race-1')! }, - driversById: { 'driver-1': mockDriverData.get('driver-1')!, 'driver-2': mockDriverData.get('driver-2')! }, - }; + this.logger.debug('Getting league protests:', query); + const presenter = new GetLeagueProtestsPresenter(); + await this.getLeagueProtestsUseCase.execute({ leagueId: query.leagueId }, presenter); + return presenter.getViewModel()!; } async getLeagueSeasons(query: GetLeagueSeasonsQuery): Promise { - console.log('Getting league seasons:', query); - return [ - { seasonId: 'season-1', name: 'Season 1', status: 'active', startDate: new Date('2025-01-01'), endDate: new Date('2025-12-31'), isPrimary: true, isParallelActive: false }, - { seasonId: 'season-2', name: 'Season 2', status: 'upcoming', startDate: new Date('2026-01-01'), endDate: new Date('2026-12-31'), isPrimary: false, isParallelActive: false }, - ]; + this.logger.debug('Getting league seasons:', query); + const presenter = new GetLeagueSeasonsPresenter(); + await this.getLeagueSeasonsUseCase.execute({ leagueId: query.leagueId }, presenter); + return presenter.getViewModel()!.seasons; + } + + async getLeagueMemberships(leagueId: string): Promise { + this.logger.debug('Getting league memberships', { leagueId }); + const presenter = new GetLeagueMembershipsPresenter(); + await this.getLeagueMembershipsUseCase.execute({ leagueId }, presenter); + return presenter.apiViewModel!; + } + + async getLeagueStandings(leagueId: string): Promise { + this.logger.debug('Getting league standings', { leagueId }); + + const presenter = new LeagueStandingsPresenter(); + await this.getLeagueStandingsUseCase.execute({ leagueId }, presenter); + return presenter.getViewModel()!; + } + + async getLeagueSchedule(leagueId: string): Promise { + this.logger.debug('Getting league schedule', { leagueId }); + const presenter = new LeagueSchedulePresenter(); + await this.getLeagueScheduleUseCase.execute({ leagueId }, presenter); + return presenter.getViewModel()!; + } + + async getLeagueStats(leagueId: string): Promise { + this.logger.debug('Getting league stats', { leagueId }); + const presenter = new LeagueStatsPresenter(); + await this.getLeagueStatsUseCase.execute({ leagueId }, presenter); + return presenter.getViewModel()!; + } + + async getLeagueAdmin(leagueId: string): Promise { + this.logger.debug('Getting league admin data', { leagueId }); + // For now, we'll keep the orchestration in the service since it combines multiple use cases + // TODO: Create a composite use case that handles all the admin data fetching + const joinRequests = await this.getLeagueJoinRequests(leagueId); + const config = await this.getLeagueFullConfig({ leagueId }); + const protests = await this.getLeagueProtests({ leagueId }); + const seasons = await this.getLeagueSeasons({ leagueId }); + + // Get owner summary - we need the ownerId, so we use a simple approach for now + // In a full implementation, we'd have a use case that gets league basic info + const ownerSummary = config ? await this.getLeagueOwnerSummary({ ownerId: 'placeholder', leagueId }) : null; + + return { + joinRequests, + ownerSummary, + config: { form: config }, + protests, + seasons, + }; + } + + async createLeague(input: CreateLeagueInput): Promise { + this.logger.debug('Creating league', { input }); + const command = { + name: input.name, + description: input.description, + ownerId: input.ownerId, + visibility: 'unranked' as const, + gameId: 'iracing', // Assume default + maxDrivers: 32, // Default value + enableDriverChampionship: true, + enableTeamChampionship: false, + enableNationsChampionship: false, + enableTrophyChampionship: false, + }; + const result = await this.createLeagueWithSeasonAndScoringUseCase.execute(command); + return { + leagueId: result.leagueId, + success: true, + }; } } diff --git a/apps/api/src/modules/league/dto/LeagueDto.ts b/apps/api/src/modules/league/dto/LeagueDto.ts index db9eab728..f96ddb7ba 100644 --- a/apps/api/src/modules/league/dto/LeagueDto.ts +++ b/apps/api/src/modules/league/dto/LeagueDto.ts @@ -559,3 +559,108 @@ export class LeagueAdminViewModel { @Type(() => LeagueSeasonSummaryViewModel) seasons: LeagueSeasonSummaryViewModel[]; } + +export class LeagueMemberDto { + @ApiProperty() + @IsString() + driverId: string; + + @ApiProperty({ type: () => DriverDto }) + @ValidateNested() + @Type(() => DriverDto) + driver: DriverDto; + + @ApiProperty({ enum: ['owner', 'manager', 'member'] }) + @IsEnum(['owner', 'manager', 'member']) + role: 'owner' | 'manager' | 'member'; + + @ApiProperty() + @IsDate() + @Type(() => Date) + joinedAt: Date; +} + +export class LeagueMembershipsViewModel { + @ApiProperty({ type: [LeagueMemberDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => LeagueMemberDto) + members: LeagueMemberDto[]; +} + +export class LeagueStandingDto { + @ApiProperty() + @IsString() + driverId: string; + + @ApiProperty({ type: () => DriverDto }) + @ValidateNested() + @Type(() => DriverDto) + driver: DriverDto; + + @ApiProperty() + @IsNumber() + points: number; + + @ApiProperty() + @IsNumber() + rank: number; +} + +export class LeagueStandingsViewModel { + @ApiProperty({ type: [LeagueStandingDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => LeagueStandingDto) + standings: LeagueStandingDto[]; +} + +export class LeagueScheduleViewModel { + @ApiProperty({ type: [RaceDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => RaceDto) + races: RaceDto[]; +} + +export class LeagueStatsViewModel { + @ApiProperty() + @IsNumber() + totalMembers: number; + + @ApiProperty() + @IsNumber() + totalRaces: number; + + @ApiProperty() + @IsNumber() + averageRating: number; +} + +export class CreateLeagueInput { + @ApiProperty() + @IsString() + name: string; + + @ApiProperty() + @IsString() + description: string; + + @ApiProperty({ enum: ['public', 'private'] }) + @IsEnum(['public', 'private']) + visibility: 'public' | 'private'; + + @ApiProperty() + @IsString() + ownerId: string; +} + +export class CreateLeagueOutput { + @ApiProperty() + @IsString() + leagueId: string; + + @ApiProperty() + @IsBoolean() + success: boolean; +} diff --git a/apps/api/src/modules/league/presenters/AllLeaguesWithCapacityPresenter.ts b/apps/api/src/modules/league/presenters/AllLeaguesWithCapacityPresenter.ts new file mode 100644 index 000000000..b676f5bae --- /dev/null +++ b/apps/api/src/modules/league/presenters/AllLeaguesWithCapacityPresenter.ts @@ -0,0 +1,30 @@ +import { IAllLeaguesWithCapacityPresenter, AllLeaguesWithCapacityResultDTO, AllLeaguesWithCapacityViewModel } from '@gridpilot/racing/application/presenters/IAllLeaguesWithCapacityPresenter'; + +export class AllLeaguesWithCapacityPresenter implements IAllLeaguesWithCapacityPresenter { + private result: AllLeaguesWithCapacityViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: AllLeaguesWithCapacityResultDTO) { + const leagues = dto.leagues.map(league => ({ + id: league.id, + name: league.name, + description: league.description, + ownerId: league.ownerId, + settings: { maxDrivers: league.settings.maxDrivers || 0 }, + createdAt: league.createdAt.toISOString(), + usedSlots: dto.memberCounts.get(league.id) || 0, + socialLinks: league.socialLinks, + })); + this.result = { + leagues, + totalCount: leagues.length, + }; + } + + getViewModel(): AllLeaguesWithCapacityViewModel | null { + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/league/presenters/ApproveLeagueJoinRequestPresenter.ts b/apps/api/src/modules/league/presenters/ApproveLeagueJoinRequestPresenter.ts new file mode 100644 index 000000000..d0d84d95c --- /dev/null +++ b/apps/api/src/modules/league/presenters/ApproveLeagueJoinRequestPresenter.ts @@ -0,0 +1,18 @@ +import { IApproveLeagueJoinRequestPresenter, ApproveLeagueJoinRequestResultDTO, ApproveLeagueJoinRequestViewModel } from '@gridpilot/racing/application/presenters/IApproveLeagueJoinRequestPresenter'; + +export class ApproveLeagueJoinRequestPresenter implements IApproveLeagueJoinRequestPresenter { + private result: ApproveLeagueJoinRequestViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: ApproveLeagueJoinRequestResultDTO) { + this.result = dto; + } + + getViewModel(): ApproveLeagueJoinRequestViewModel { + 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/league/presenters/GetLeagueAdminPermissionsPresenter.ts b/apps/api/src/modules/league/presenters/GetLeagueAdminPermissionsPresenter.ts new file mode 100644 index 000000000..4e913f4ae --- /dev/null +++ b/apps/api/src/modules/league/presenters/GetLeagueAdminPermissionsPresenter.ts @@ -0,0 +1,17 @@ +import { IGetLeagueAdminPermissionsPresenter, GetLeagueAdminPermissionsResultDTO, GetLeagueAdminPermissionsViewModel } from '@gridpilot/racing/application/presenters/IGetLeagueAdminPermissionsPresenter'; + +export class GetLeagueAdminPermissionsPresenter implements IGetLeagueAdminPermissionsPresenter { + private result: GetLeagueAdminPermissionsViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: GetLeagueAdminPermissionsResultDTO) { + this.result = dto; + } + + getViewModel(): GetLeagueAdminPermissionsViewModel | null { + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/league/presenters/GetLeagueMembershipsPresenter.ts b/apps/api/src/modules/league/presenters/GetLeagueMembershipsPresenter.ts new file mode 100644 index 000000000..5006a1ccc --- /dev/null +++ b/apps/api/src/modules/league/presenters/GetLeagueMembershipsPresenter.ts @@ -0,0 +1,47 @@ +import { IGetLeagueMembershipsPresenter, GetLeagueMembershipsResultDTO, GetLeagueMembershipsViewModel } from '@gridpilot/racing/application/presenters/IGetLeagueMembershipsPresenter'; +import { LeagueMembershipsViewModel } from '../dto/LeagueDto'; + +export class GetLeagueMembershipsPresenter implements IGetLeagueMembershipsPresenter { + private result: GetLeagueMembershipsViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: GetLeagueMembershipsResultDTO) { + const driverMap = new Map(dto.drivers.map(d => [d.id, d])); + const members = dto.memberships.map(membership => ({ + driverId: membership.driverId, + driver: driverMap.get(membership.driverId)!, + role: this.mapRole(membership.role) as 'owner' | 'manager' | 'member', + joinedAt: membership.joinedAt, + })); + this.result = { memberships: { members } }; + } + + private mapRole(role: string): 'owner' | 'manager' | 'member' { + switch (role) { + case 'owner': + return 'owner'; + case 'admin': + return 'manager'; // Map admin to manager for API + case 'steward': + return 'member'; // Map steward to member for API + case 'member': + return 'member'; + default: + return 'member'; + } + } + + getViewModel(): GetLeagueMembershipsViewModel { + if (!this.result) throw new Error('Presenter not presented'); + return this.result; + } + + // API-specific method + get apiViewModel(): LeagueMembershipsViewModel | null { + if (!this.result?.memberships) return null; + return this.result.memberships as LeagueMembershipsViewModel; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/league/presenters/GetLeagueOwnerSummaryPresenter.ts b/apps/api/src/modules/league/presenters/GetLeagueOwnerSummaryPresenter.ts new file mode 100644 index 000000000..95adbe070 --- /dev/null +++ b/apps/api/src/modules/league/presenters/GetLeagueOwnerSummaryPresenter.ts @@ -0,0 +1,18 @@ +import { IGetLeagueOwnerSummaryPresenter, GetLeagueOwnerSummaryResultDTO, GetLeagueOwnerSummaryViewModel } from '@gridpilot/racing/application/presenters/IGetLeagueOwnerSummaryPresenter'; + +export class GetLeagueOwnerSummaryPresenter implements IGetLeagueOwnerSummaryPresenter { + private result: GetLeagueOwnerSummaryViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: GetLeagueOwnerSummaryResultDTO) { + this.result = { summary: dto.summary }; + } + + getViewModel(): GetLeagueOwnerSummaryViewModel { + 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/league/presenters/GetLeagueProtestsPresenter.ts b/apps/api/src/modules/league/presenters/GetLeagueProtestsPresenter.ts new file mode 100644 index 000000000..66f909947 --- /dev/null +++ b/apps/api/src/modules/league/presenters/GetLeagueProtestsPresenter.ts @@ -0,0 +1,30 @@ +import { IGetLeagueProtestsPresenter, GetLeagueProtestsResultDTO, GetLeagueProtestsViewModel } from '@gridpilot/racing/application/presenters/IGetLeagueProtestsPresenter'; + +export class GetLeagueProtestsPresenter implements IGetLeagueProtestsPresenter { + private result: GetLeagueProtestsViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: GetLeagueProtestsResultDTO) { + const racesById = {}; + dto.races.forEach(race => { + racesById[race.id] = race; + }); + const driversById = {}; + dto.drivers.forEach(driver => { + driversById[driver.id] = driver; + }); + this.result = { + protests: dto.protests, + racesById, + driversById, + }; + } + + getViewModel(): GetLeagueProtestsViewModel { + 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/league/presenters/GetLeagueSeasonsPresenter.ts b/apps/api/src/modules/league/presenters/GetLeagueSeasonsPresenter.ts new file mode 100644 index 000000000..323fe50be --- /dev/null +++ b/apps/api/src/modules/league/presenters/GetLeagueSeasonsPresenter.ts @@ -0,0 +1,27 @@ +import { IGetLeagueSeasonsPresenter, GetLeagueSeasonsResultDTO, GetLeagueSeasonsViewModel } from '@gridpilot/racing/application/presenters/IGetLeagueSeasonsPresenter'; + +export class GetLeagueSeasonsPresenter implements IGetLeagueSeasonsPresenter { + private result: GetLeagueSeasonsViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: GetLeagueSeasonsResultDTO) { + const seasons = dto.seasons.map(season => ({ + seasonId: season.id, + name: season.name, + status: season.status, + startDate: season.startDate, + endDate: season.endDate, + isPrimary: season.isPrimary, + isParallelActive: season.isParallelActive, + })); + this.result = { seasons }; + } + + getViewModel(): GetLeagueSeasonsViewModel { + 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/league/presenters/LeagueAdminPresenter.ts b/apps/api/src/modules/league/presenters/LeagueAdminPresenter.ts new file mode 100644 index 000000000..9b0b6c4fa --- /dev/null +++ b/apps/api/src/modules/league/presenters/LeagueAdminPresenter.ts @@ -0,0 +1,30 @@ +import { LeagueAdminViewModel } from '../dto/LeagueDto'; + +export class LeagueAdminPresenter { + private result: LeagueAdminViewModel | null = null; + + reset() { + this.result = null; + } + + present(data: { + joinRequests: any[]; + ownerSummary: any; + config: any; + protests: any; + seasons: any[]; + }) { + this.result = { + joinRequests: data.joinRequests, + ownerSummary: data.ownerSummary, + config: { form: data.config }, + protests: data.protests, + seasons: data.seasons, + }; + } + + getViewModel(): LeagueAdminViewModel { + 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/league/presenters/LeagueConfigPresenter.ts b/apps/api/src/modules/league/presenters/LeagueConfigPresenter.ts new file mode 100644 index 000000000..19622701b --- /dev/null +++ b/apps/api/src/modules/league/presenters/LeagueConfigPresenter.ts @@ -0,0 +1,109 @@ +import { ILeagueFullConfigPresenter, LeagueFullConfigData, LeagueConfigFormViewModel } from '@gridpilot/racing/application/presenters/ILeagueFullConfigPresenter'; +import { LeagueConfigFormModelDto } from '../dto/LeagueDto'; + +export class LeagueConfigPresenter implements ILeagueFullConfigPresenter { + private result: LeagueConfigFormViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: LeagueFullConfigData) { + // Map from LeagueFullConfigData to LeagueConfigFormViewModel + const league = dto.league; + const settings = league.settings; + const stewarding = settings.stewarding; + + this.result = { + leagueId: league.id, + basics: { + name: league.name, + description: league.description, + visibility: 'public', // TODO: Map visibility from league + gameId: 'iracing', // TODO: Map from game + }, + structure: { + mode: 'solo', // TODO: Map from league settings + maxDrivers: settings.maxDrivers || 32, + multiClassEnabled: false, // TODO: Map + }, + championships: { + enableDriverChampionship: true, // TODO: Map + enableTeamChampionship: false, + enableNationsChampionship: false, + enableTrophyChampionship: false, + }, + scoring: { + customScoringEnabled: false, // TODO: Map + }, + dropPolicy: { + strategy: 'none', // TODO: Map + }, + timings: { + practiceMinutes: 30, // TODO: Map + qualifyingMinutes: 15, + mainRaceMinutes: 60, + sessionCount: 1, + roundsPlanned: 10, // TODO: Map + }, + stewarding: { + decisionMode: stewarding?.decisionMode || 'admin_only', + requireDefense: stewarding?.requireDefense || false, + defenseTimeLimit: stewarding?.defenseTimeLimit || 48, + voteTimeLimit: stewarding?.voteTimeLimit || 72, + protestDeadlineHours: stewarding?.protestDeadlineHours || 48, + stewardingClosesHours: stewarding?.stewardingClosesHours || 168, + notifyAccusedOnProtest: stewarding?.notifyAccusedOnProtest || true, + notifyOnVoteRequired: stewarding?.notifyOnVoteRequired || true, + requiredVotes: stewarding?.requiredVotes, + }, + }; + } + + getViewModel(): LeagueConfigFormViewModel | null { + return this.result; + } + + // API-specific method to get the DTO + get viewModel(): LeagueConfigFormModelDto | null { + if (!this.result) return null; + + // Map from LeagueConfigFormViewModel to LeagueConfigFormModelDto + return { + leagueId: this.result.leagueId, + basics: { + name: this.result.basics.name, + description: this.result.basics.description, + visibility: this.result.basics.visibility as 'public' | 'private', + }, + structure: { + mode: this.result.structure.mode as 'solo' | 'team', + }, + championships: [], // TODO: Map championships + scoring: { + type: 'standard', // TODO: Map scoring type + points: 25, // TODO: Map points + }, + dropPolicy: { + strategy: this.result.dropPolicy.strategy as 'none' | 'worst_n', + n: this.result.dropPolicy.n, + }, + timings: { + raceDayOfWeek: 'sunday', // TODO: Map from timings + raceTimeHour: 20, + raceTimeMinute: 0, + }, + stewarding: { + decisionMode: this.result.stewarding.decisionMode === 'steward_vote' ? 'committee_vote' : 'single_steward', + requireDefense: this.result.stewarding.requireDefense, + defenseTimeLimit: this.result.stewarding.defenseTimeLimit, + voteTimeLimit: this.result.stewarding.voteTimeLimit, + protestDeadlineHours: this.result.stewarding.protestDeadlineHours, + stewardingClosesHours: this.result.stewarding.stewardingClosesHours, + notifyAccusedOnProtest: this.result.stewarding.notifyAccusedOnProtest, + notifyOnVoteRequired: this.result.stewarding.notifyOnVoteRequired, + requiredVotes: this.result.stewarding.requiredVotes, + }, + }; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/league/presenters/LeagueJoinRequestsPresenter.ts b/apps/api/src/modules/league/presenters/LeagueJoinRequestsPresenter.ts new file mode 100644 index 000000000..0dda51067 --- /dev/null +++ b/apps/api/src/modules/league/presenters/LeagueJoinRequestsPresenter.ts @@ -0,0 +1,27 @@ +import { IGetLeagueJoinRequestsPresenter, GetLeagueJoinRequestsResultDTO, GetLeagueJoinRequestsViewModel } from '@gridpilot/racing/application/presenters/IGetLeagueJoinRequestsPresenter'; + +export class LeagueJoinRequestsPresenter implements IGetLeagueJoinRequestsPresenter { + private result: GetLeagueJoinRequestsViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: GetLeagueJoinRequestsResultDTO) { + const driverMap = new Map(dto.drivers.map(d => [d.id, d])); + const joinRequests = dto.joinRequests.map(request => ({ + id: request.id, + leagueId: request.leagueId, + driverId: request.driverId, + requestedAt: request.requestedAt, + message: request.message, + driver: driverMap.get(request.driverId) || null, + })); + this.result = { joinRequests }; + } + + getViewModel(): GetLeagueJoinRequestsViewModel { + 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/league/presenters/LeagueSchedulePresenter.ts b/apps/api/src/modules/league/presenters/LeagueSchedulePresenter.ts new file mode 100644 index 000000000..dc0212d4d --- /dev/null +++ b/apps/api/src/modules/league/presenters/LeagueSchedulePresenter.ts @@ -0,0 +1,23 @@ +import { IGetLeagueSchedulePresenter, GetLeagueScheduleResultDTO, LeagueScheduleViewModel } from '@gridpilot/racing/application/presenters/IGetLeagueSchedulePresenter'; + +export class LeagueSchedulePresenter implements IGetLeagueSchedulePresenter { + private result: LeagueScheduleViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: GetLeagueScheduleResultDTO) { + this.result = { + races: dto.races.map(race => ({ + id: race.id, + name: race.name, + date: race.scheduledAt.toISOString(), + })), + }; + } + + getViewModel(): LeagueScheduleViewModel | null { + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/league/presenters/LeagueStandingsPresenter.ts b/apps/api/src/modules/league/presenters/LeagueStandingsPresenter.ts new file mode 100644 index 000000000..fc02d5762 --- /dev/null +++ b/apps/api/src/modules/league/presenters/LeagueStandingsPresenter.ts @@ -0,0 +1,27 @@ +import { ILeagueStandingsPresenter, LeagueStandingsResultDTO, LeagueStandingsViewModel } from '@gridpilot/racing/application/presenters/ILeagueStandingsPresenter'; + +export class LeagueStandingsPresenter implements ILeagueStandingsPresenter { + private result: LeagueStandingsViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: LeagueStandingsResultDTO) { + const driverMap = new Map(dto.drivers.map(d => [d.id, { id: d.id, name: d.name }])); + const standings = dto.standings + .sort((a, b) => a.position - b.position) + .map(standing => ({ + driverId: standing.driverId, + driver: driverMap.get(standing.driverId)!, + points: standing.points, + rank: standing.position, + })); + this.result = { standings }; + } + + getViewModel(): LeagueStandingsViewModel { + 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/league/presenters/LeagueStatsPresenter.ts b/apps/api/src/modules/league/presenters/LeagueStatsPresenter.ts new file mode 100644 index 000000000..fb965e553 --- /dev/null +++ b/apps/api/src/modules/league/presenters/LeagueStatsPresenter.ts @@ -0,0 +1,17 @@ +import { ILeagueStatsPresenter, LeagueStatsResultDTO, LeagueStatsViewModel } from '@gridpilot/racing/application/presenters/ILeagueStatsPresenter'; + +export class LeagueStatsPresenter implements ILeagueStatsPresenter { + private result: LeagueStatsViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: LeagueStatsResultDTO) { + this.result = dto; + } + + getViewModel(): LeagueStatsViewModel | null { + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/league/presenters/RejectLeagueJoinRequestPresenter.ts b/apps/api/src/modules/league/presenters/RejectLeagueJoinRequestPresenter.ts new file mode 100644 index 000000000..570d35785 --- /dev/null +++ b/apps/api/src/modules/league/presenters/RejectLeagueJoinRequestPresenter.ts @@ -0,0 +1,18 @@ +import { IRejectLeagueJoinRequestPresenter, RejectLeagueJoinRequestResultDTO, RejectLeagueJoinRequestViewModel } from '@gridpilot/racing/application/presenters/IRejectLeagueJoinRequestPresenter'; + +export class RejectLeagueJoinRequestPresenter implements IRejectLeagueJoinRequestPresenter { + private result: RejectLeagueJoinRequestViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: RejectLeagueJoinRequestResultDTO) { + this.result = dto; + } + + getViewModel(): RejectLeagueJoinRequestViewModel { + 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/league/presenters/RemoveLeagueMemberPresenter.ts b/apps/api/src/modules/league/presenters/RemoveLeagueMemberPresenter.ts new file mode 100644 index 000000000..e330053e6 --- /dev/null +++ b/apps/api/src/modules/league/presenters/RemoveLeagueMemberPresenter.ts @@ -0,0 +1,18 @@ +import { IRemoveLeagueMemberPresenter, RemoveLeagueMemberResultDTO, RemoveLeagueMemberViewModel } from '@gridpilot/racing/application/presenters/IRemoveLeagueMemberPresenter'; + +export class RemoveLeagueMemberPresenter implements IRemoveLeagueMemberPresenter { + private result: RemoveLeagueMemberViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: RemoveLeagueMemberResultDTO) { + this.result = dto; + } + + getViewModel(): RemoveLeagueMemberViewModel { + 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/league/presenters/TotalLeaguesPresenter.ts b/apps/api/src/modules/league/presenters/TotalLeaguesPresenter.ts new file mode 100644 index 000000000..bce8c354a --- /dev/null +++ b/apps/api/src/modules/league/presenters/TotalLeaguesPresenter.ts @@ -0,0 +1,20 @@ +import { IGetTotalLeaguesPresenter, GetTotalLeaguesResultDTO, GetTotalLeaguesViewModel } from '@gridpilot/racing/application/presenters/IGetTotalLeaguesPresenter'; +import { LeagueStatsDto } from '../dto/LeagueDto'; + +export class TotalLeaguesPresenter implements IGetTotalLeaguesPresenter { + private result: LeagueStatsDto | null = null; + + reset() { + this.result = null; + } + + present(dto: GetTotalLeaguesResultDTO) { + this.result = { + totalLeagues: dto.totalLeagues, + }; + } + + getViewModel(): LeagueStatsDto | null { + return this.result; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/league/presenters/UpdateLeagueMemberRolePresenter.ts b/apps/api/src/modules/league/presenters/UpdateLeagueMemberRolePresenter.ts new file mode 100644 index 000000000..dea709e24 --- /dev/null +++ b/apps/api/src/modules/league/presenters/UpdateLeagueMemberRolePresenter.ts @@ -0,0 +1,18 @@ +import { IUpdateLeagueMemberRolePresenter, UpdateLeagueMemberRoleResultDTO, UpdateLeagueMemberRoleViewModel } from '@gridpilot/racing/application/presenters/IUpdateLeagueMemberRolePresenter'; + +export class UpdateLeagueMemberRolePresenter implements IUpdateLeagueMemberRolePresenter { + private result: UpdateLeagueMemberRoleViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: UpdateLeagueMemberRoleResultDTO) { + this.result = dto; + } + + getViewModel(): UpdateLeagueMemberRoleViewModel { + 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/media/MediaModule.ts b/apps/api/src/modules/media/MediaModule.ts index 56fc57711..7d52a9a0d 100644 --- a/apps/api/src/modules/media/MediaModule.ts +++ b/apps/api/src/modules/media/MediaModule.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { MediaService } from './MediaService'; import { MediaController } from './MediaController'; +import { MediaProviders } from './MediaProviders'; @Module({ controllers: [MediaController], - providers: [MediaService], + providers: MediaProviders, exports: [MediaService], }) export class MediaModule {} diff --git a/apps/api/src/modules/media/MediaProviders.ts b/apps/api/src/modules/media/MediaProviders.ts index e5c7819c8..fc1288bf3 100644 --- a/apps/api/src/modules/media/MediaProviders.ts +++ b/apps/api/src/modules/media/MediaProviders.ts @@ -7,7 +7,7 @@ import { MediaService } from './MediaService'; /* import { IAvatarGenerationRepository } from 'core/media/domain/repositories/IAvatarGenerationRepository'; import { FaceValidationPort } from 'core/media/application/ports/FaceValidationPort'; - import { Logger } from 'core/shared/logging/Logger'; + import { Logger } from '@gridpilot/shared/logging/Logger'; import { InMemoryAvatarGenerationRepository } from 'adapters/media/persistence/inmemory/InMemoryAvatarGenerationRepository'; import { InMemoryFaceValidationAdapter } from 'adapters/media/ports/InMemoryFaceValidationAdapter'; diff --git a/apps/api/src/modules/payments/PaymentsModule.ts b/apps/api/src/modules/payments/PaymentsModule.ts index e20bb67c0..d6b4b601a 100644 --- a/apps/api/src/modules/payments/PaymentsModule.ts +++ b/apps/api/src/modules/payments/PaymentsModule.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { PaymentsService } from './PaymentsService'; import { PaymentsController } from './PaymentsController'; +import { PaymentsProviders } from './PaymentsProviders'; @Module({ controllers: [PaymentsController], - providers: [PaymentsService], + providers: PaymentsProviders, exports: [PaymentsService], }) export class PaymentsModule {} diff --git a/apps/api/src/modules/payments/PaymentsProviders.ts b/apps/api/src/modules/payments/PaymentsProviders.ts index c6e2359dc..f8bf805b6 100644 --- a/apps/api/src/modules/payments/PaymentsProviders.ts +++ b/apps/api/src/modules/payments/PaymentsProviders.ts @@ -11,7 +11,7 @@ import { IMembershipFeeRepository } from 'core/payments/domain/repositories/IMem 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 'core/shared/logging/Logger'; +import { Logger } from '@gridpilot/shared/logging/Logger'; // Import concrete in-memory implementations import { InMemoryPaymentRepository } from 'adapters/payments/persistence/inmemory/InMemoryPaymentRepository'; diff --git a/apps/api/src/modules/race/RaceModule.ts b/apps/api/src/modules/race/RaceModule.ts index 512935562..1457eaa70 100644 --- a/apps/api/src/modules/race/RaceModule.ts +++ b/apps/api/src/modules/race/RaceModule.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { RaceService } from './RaceService'; import { RaceController } from './RaceController'; +import { RaceProviders } from './RaceProviders'; @Module({ controllers: [RaceController], - providers: [RaceService], + providers: RaceProviders, exports: [RaceService], }) export class RaceModule {} diff --git a/apps/api/src/modules/sponsor/SponsorModule.ts b/apps/api/src/modules/sponsor/SponsorModule.ts index 920dcc39f..884f821a9 100644 --- a/apps/api/src/modules/sponsor/SponsorModule.ts +++ b/apps/api/src/modules/sponsor/SponsorModule.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { SponsorService } from './SponsorService'; import { SponsorController } from './SponsorController'; +import { SponsorProviders } from './SponsorProviders'; @Module({ controllers: [SponsorController], - providers: [SponsorService], + providers: SponsorProviders, exports: [SponsorService], }) export class SponsorModule {} diff --git a/apps/api/src/modules/team/TeamModule.ts b/apps/api/src/modules/team/TeamModule.ts index dea447525..d1bfe12c0 100644 --- a/apps/api/src/modules/team/TeamModule.ts +++ b/apps/api/src/modules/team/TeamModule.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { TeamService } from './TeamService'; import { TeamController } from './TeamController'; +import { TeamProviders } from './TeamProviders'; @Module({ controllers: [TeamController], - providers: [TeamService], + providers: TeamProviders, exports: [TeamService], }) export class TeamModule {} diff --git a/apps/api/src/modules/team/TeamProviders.ts b/apps/api/src/modules/team/TeamProviders.ts index 56da8ecc3..2bd3aa5e0 100644 --- a/apps/api/src/modules/team/TeamProviders.ts +++ b/apps/api/src/modules/team/TeamProviders.ts @@ -1,5 +1,54 @@ +import { Provider } from '@nestjs/common'; import { TeamService } from './TeamService'; -export const TeamProviders = [ - TeamService, +// Import core interfaces +import { ITeamRepository } from '@gridpilot/racing/domain/repositories/ITeamRepository'; +import { ITeamMembershipRepository } from '@gridpilot/racing/domain/repositories/ITeamMembershipRepository'; +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 { 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'; + +// Tokens +export const TEAM_REPOSITORY_TOKEN = 'ITeamRepository'; +export const TEAM_MEMBERSHIP_REPOSITORY_TOKEN = 'ITeamMembershipRepository'; +export const TEAM_GET_ALL_USE_CASE_TOKEN = 'GetAllTeamsUseCase'; +export const TEAM_GET_DRIVER_TEAM_USE_CASE_TOKEN = 'GetDriverTeamUseCase'; +export const TEAM_LOGGER_TOKEN = 'Logger'; + +export const TeamProviders: Provider[] = [ + TeamService, // Provide the service itself + { + provide: TEAM_REPOSITORY_TOKEN, + useFactory: (logger: Logger) => new InMemoryTeamRepository(logger), + inject: [TEAM_LOGGER_TOKEN], + }, + { + provide: TEAM_MEMBERSHIP_REPOSITORY_TOKEN, + useFactory: (logger: Logger) => new InMemoryTeamMembershipRepository(logger), + inject: [TEAM_LOGGER_TOKEN], + }, + { + provide: TEAM_LOGGER_TOKEN, + useClass: ConsoleLogger, + }, + // Use cases + { + provide: TEAM_GET_ALL_USE_CASE_TOKEN, + useFactory: (teamRepo: ITeamRepository, membershipRepo: ITeamMembershipRepository, logger: Logger) => + new GetAllTeamsUseCase(teamRepo, membershipRepo, logger), + inject: [TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, TEAM_LOGGER_TOKEN], + }, + { + provide: TEAM_GET_DRIVER_TEAM_USE_CASE_TOKEN, + useFactory: (teamRepo: ITeamRepository, membershipRepo: ITeamMembershipRepository, logger: Logger) => + new GetDriverTeamUseCase(teamRepo, membershipRepo, logger), + inject: [TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, TEAM_LOGGER_TOKEN], + }, ]; diff --git a/apps/api/src/modules/team/TeamService.spec.ts b/apps/api/src/modules/team/TeamService.spec.ts new file mode 100644 index 000000000..085fbc913 --- /dev/null +++ b/apps/api/src/modules/team/TeamService.spec.ts @@ -0,0 +1,168 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TeamService } from './TeamService'; +import { GetAllTeamsUseCase } from '@gridpilot/racing/application/use-cases/GetAllTeamsUseCase'; +import { GetDriverTeamUseCase } from '@gridpilot/racing/application/use-cases/GetDriverTeamUseCase'; +import { Logger } from '@gridpilot/shared/application/Logger'; +import { AllTeamsPresenter } from './presenters/AllTeamsPresenter'; +import { DriverTeamPresenter } from './presenters/DriverTeamPresenter'; +import { AllTeamsViewModel, DriverTeamViewModel, GetDriverTeamQuery } from './dto/TeamDto'; +import { TEAM_GET_ALL_USE_CASE_TOKEN, TEAM_GET_DRIVER_TEAM_USE_CASE_TOKEN, TEAM_LOGGER_TOKEN } from './TeamProviders'; + +describe('TeamService', () => { + let service: TeamService; + let getAllTeamsUseCase: jest.Mocked; + let getDriverTeamUseCase: jest.Mocked; + let logger: jest.Mocked; + + beforeEach(async () => { + const mockGetAllTeamsUseCase = { + execute: jest.fn(), + }; + const mockGetDriverTeamUseCase = { + execute: jest.fn(), + }; + const mockLogger = { + debug: jest.fn(), + info: jest.fn(), + error: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TeamService, + { + provide: TEAM_GET_ALL_USE_CASE_TOKEN, + useValue: mockGetAllTeamsUseCase, + }, + { + provide: TEAM_GET_DRIVER_TEAM_USE_CASE_TOKEN, + useValue: mockGetDriverTeamUseCase, + }, + { + provide: TEAM_LOGGER_TOKEN, + useValue: mockLogger, + }, + ], + }).compile(); + + service = module.get(TeamService); + getAllTeamsUseCase = module.get(TEAM_GET_ALL_USE_CASE_TOKEN); + getDriverTeamUseCase = module.get(TEAM_GET_DRIVER_TEAM_USE_CASE_TOKEN); + logger = module.get(TEAM_LOGGER_TOKEN); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getAllTeams', () => { + it('should create presenter, call use case, and return view model', async () => { + const mockViewModel: AllTeamsViewModel = { + teams: [], + totalCount: 0, + }; + + const mockPresenter = { + reset: jest.fn(), + present: jest.fn(), + get viewModel(): AllTeamsViewModel { + return mockViewModel; + }, + }; + + // Mock the presenter constructor + const originalConstructor = AllTeamsPresenter; + (AllTeamsPresenter as any) = jest.fn().mockImplementation(() => mockPresenter); + + // Mock the use case to call the presenter + getAllTeamsUseCase.execute.mockImplementation(async (input, presenter) => { + presenter.present({ teams: [] }); + }); + + const result = await service.getAllTeams(); + + expect(AllTeamsPresenter).toHaveBeenCalled(); + expect(getAllTeamsUseCase.execute).toHaveBeenCalledWith(undefined, mockPresenter); + expect(result).toBe(mockViewModel); + + // Restore + AllTeamsPresenter = originalConstructor; + }); + }); + + describe('getDriverTeam', () => { + it('should create presenter, call use case, and return view model', async () => { + const query: GetDriverTeamQuery = { teamId: 'team1', driverId: 'driver1' }; + const mockViewModel: DriverTeamViewModel = { + team: { + id: 'team1', + name: 'Team 1', + tag: 'T1', + description: 'Description', + ownerId: 'driver1', + leagues: [], + }, + membership: { + role: 'owner' as any, + joinedAt: new Date(), + isActive: true, + }, + isOwner: true, + canManage: true, + }; + + const mockPresenter = { + reset: jest.fn(), + present: jest.fn(), + get viewModel(): DriverTeamViewModel { + return mockViewModel; + }, + }; + + // Mock the presenter constructor + const originalConstructor = DriverTeamPresenter; + (DriverTeamPresenter as any) = jest.fn().mockImplementation(() => mockPresenter); + + // Mock the use case to call the presenter + getDriverTeamUseCase.execute.mockImplementation(async (input, presenter) => { + presenter.present({ + team: { + id: 'team1', + name: 'Team 1', + tag: 'T1', + description: 'Description', + ownerId: 'driver1', + leagues: [], + }, + membership: { + role: 'owner', + status: 'active', + joinedAt: new Date(), + }, + driverId: 'driver1', + }); + }); + + const result = await service.getDriverTeam(query); + + expect(DriverTeamPresenter).toHaveBeenCalled(); + expect(getDriverTeamUseCase.execute).toHaveBeenCalledWith({ driverId: 'driver1' }, mockPresenter); + expect(result).toBe(mockViewModel); + + // Restore + DriverTeamPresenter = originalConstructor; + }); + + it('should return null on error', async () => { + const query: GetDriverTeamQuery = { teamId: 'team1', driverId: 'driver1' }; + + // Mock the use case to throw an error + getDriverTeamUseCase.execute.mockRejectedValue(new Error('Team not found')); + + const result = await service.getDriverTeam(query); + + expect(result).toBeNull(); + expect(logger.error).toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/apps/api/src/modules/team/TeamService.ts b/apps/api/src/modules/team/TeamService.ts index 61ccf9f93..e707f505b 100644 --- a/apps/api/src/modules/team/TeamService.ts +++ b/apps/api/src/modules/team/TeamService.ts @@ -1,43 +1,46 @@ -import { Injectable } from '@nestjs/common'; -import { AllTeamsViewModel, GetDriverTeamQuery, DriverTeamViewModel, TeamDto, MembershipDto, TeamLeagueDto, MembershipRole } from './dto/TeamDto'; +import { Injectable, Inject } from '@nestjs/common'; +import { AllTeamsViewModel, GetDriverTeamQuery, DriverTeamViewModel } from './dto/TeamDto'; + +// Use cases +import { GetAllTeamsUseCase } from '@gridpilot/racing/application/use-cases/GetAllTeamsUseCase'; +import { GetDriverTeamUseCase } from '@gridpilot/racing/application/use-cases/GetDriverTeamUseCase'; + +// Presenters +import { AllTeamsPresenter } from './presenters/AllTeamsPresenter'; +import { DriverTeamPresenter } from './presenters/DriverTeamPresenter'; + +// 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'; @Injectable() export class TeamService { - getAllTeams(): Promise { - // TODO: Implement actual logic to fetch all teams - return Promise.resolve({ - teams: [], - totalCount: 0, - }); - } + 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_LOGGER_TOKEN) private readonly logger: Logger, + ) {} - private teams: Map = new Map(); // In-memory store for teams + async getAllTeams(): Promise { + this.logger.debug('[TeamService] Fetching all teams.'); + + const presenter = new AllTeamsPresenter(); + await this.getAllTeamsUseCase.execute(undefined, presenter); + return presenter.viewModel; + } async getDriverTeam(query: GetDriverTeamQuery): Promise { - const { teamId, driverId } = query; + this.logger.debug(`[TeamService] Fetching driver team for driverId: ${query.driverId}`); - const team = this.teams.get(teamId); - if (!team) { + const presenter = new DriverTeamPresenter(); + try { + await this.getDriverTeamUseCase.execute({ driverId: query.driverId }, presenter); + return presenter.viewModel; + } catch (error) { + this.logger.error(`Error fetching driver team: ${error}`); return null; } - - // Mock membership and roles - const membership: MembershipDto = { - role: driverId === team.ownerId ? MembershipRole.OWNER : MembershipRole.MEMBER, - joinedAt: new Date(Date.now() - 86400000 * 30), // Joined 30 days ago - isActive: true, // Always active for mock - }; - - const isOwner = team.ownerId === driverId; - const canManage = isOwner || membership.role === MembershipRole.MANAGER; - - return { - team: team, - membership, - isOwner, - canManage, - }; } - - // Add other methods related to Team logic here based on other presenters } diff --git a/apps/api/src/modules/team/dto/TeamDto.ts b/apps/api/src/modules/team/dto/TeamDto.ts index 16d19bfe4..c3c70c1ec 100644 --- a/apps/api/src/modules/team/dto/TeamDto.ts +++ b/apps/api/src/modules/team/dto/TeamDto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsEnum, IsBoolean, IsDate, IsOptional } from 'class-validator'; export class TeamLeagueDto { @ApiProperty() @@ -37,7 +38,7 @@ export class AllTeamsViewModel { @ApiProperty() totalCount: number; -import { IsString, IsNotEmpty, IsEnum, IsBoolean, IsDate } from 'class-validator'; +} export class TeamDto { @ApiProperty() diff --git a/apps/api/src/modules/team/presenters/AllTeamsPresenter.ts b/apps/api/src/modules/team/presenters/AllTeamsPresenter.ts new file mode 100644 index 000000000..3f59d7acf --- /dev/null +++ b/apps/api/src/modules/team/presenters/AllTeamsPresenter.ts @@ -0,0 +1,31 @@ +import { IAllTeamsPresenter, AllTeamsResultDTO, AllTeamsViewModel } from '@gridpilot/racing/application/presenters/IAllTeamsPresenter'; +import { TeamListItemViewModel } from '../dto/TeamDto'; + +export class AllTeamsPresenter implements IAllTeamsPresenter { + private result: AllTeamsViewModel | null = null; + + reset() { + this.result = null; + } + + present(dto: AllTeamsResultDTO) { + const teams: TeamListItemViewModel[] = dto.teams.map(team => ({ + id: team.id, + name: team.name, + tag: team.tag, + description: team.description, + memberCount: team.memberCount, + leagues: team.leagues || [], + })); + + this.result = { + teams, + totalCount: teams.length, + }; + } + + get viewModel(): AllTeamsViewModel { + 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/DriverTeamPresenter.ts b/apps/api/src/modules/team/presenters/DriverTeamPresenter.ts new file mode 100644 index 000000000..06d7af71b --- /dev/null +++ b/apps/api/src/modules/team/presenters/DriverTeamPresenter.ts @@ -0,0 +1,42 @@ +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; + + reset() { + this.result = null; + } + + 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; + + this.result = { + team, + membership, + isOwner, + canManage, + }; + } + + get viewModel(): DriverTeamViewModel { + 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 67da5f967..91a6fa485 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -31,6 +31,15 @@ "@gridpilot/shared/*": [ "../../core/shared/*" ], + "@gridpilot/shared/application/*": [ + "../../core/shared/application/*" + ], + "@gridpilot/racing/*": [ + "../../core/racing/*" + ], + "@gridpilot/league/*": [ + "../../core/league/*" + ], "@gridpilot/analytics/*": [ "../../core/analytics/*" ], @@ -52,6 +61,9 @@ "@gridpilot/core/shared/logging/*": [ "../../core/shared/logging/*" ], + "adapters/*": [ + "../../adapters/*" + ], "@nestjs/testing": [ "./node_modules/@nestjs/testing" ] diff --git a/apps/website/app/layout.tsx b/apps/website/app/layout.tsx index 1a48bd943..921f46e78 100644 --- a/apps/website/app/layout.tsx +++ b/apps/website/app/layout.tsx @@ -11,6 +11,7 @@ import AlphaFooter from '@/components/alpha/AlphaFooter'; import { AuthProvider } from '@/lib/auth/AuthContext'; import NotificationProvider from '@/components/notifications/NotificationProvider'; import DevToolbar from '@/components/dev/DevToolbar'; +import { initializeDIContainer } from '@/lib/di-setup'; export const dynamic = 'force-dynamic'; @@ -49,6 +50,7 @@ export default async function RootLayout({ }: { children: React.ReactNode; }) { + await initializeDIContainer(); const mode = getAppMode(); if (mode === 'alpha') { diff --git a/apps/website/components/leagues/LeagueSchedule.tsx b/apps/website/components/leagues/LeagueSchedule.tsx index 68c26bed8..26e207750 100644 --- a/apps/website/components/leagues/LeagueSchedule.tsx +++ b/apps/website/components/leagues/LeagueSchedule.tsx @@ -3,12 +3,8 @@ import { useState, useEffect, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { useEffectiveDriverId } from '@/lib/currentDriver'; -import { - loadLeagueSchedule, - registerForRace, - withdrawFromRace, - type LeagueScheduleRaceItemViewModel, -} from '@/lib/presenters/LeagueSchedulePresenter'; +import { createLeagueSchedulePresenter } from '@/lib/presenters/factories'; +import type { LeagueScheduleRaceItemViewModel } from '@/lib/presenters/LeagueSchedulePresenter'; interface LeagueScheduleProps { leagueId: string; diff --git a/apps/website/components/leagues/ScheduleRaceForm.tsx b/apps/website/components/leagues/ScheduleRaceForm.tsx index dc15a6bda..9e994fddc 100644 --- a/apps/website/components/leagues/ScheduleRaceForm.tsx +++ b/apps/website/components/leagues/ScheduleRaceForm.tsx @@ -4,12 +4,11 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import Button from '../ui/Button'; import Input from '../ui/Input'; -import { - loadScheduleRaceFormLeagues, - scheduleRaceFromForm, - type ScheduleRaceFormData, - type ScheduledRaceViewModel, - type LeagueOptionViewModel, +import { createScheduleRaceFormPresenter } from '@/lib/presenters/factories'; +import type { + ScheduleRaceFormData, + ScheduledRaceViewModel, + LeagueOptionViewModel, } from '@/lib/presenters/ScheduleRaceFormPresenter'; interface ScheduleRaceFormProps { diff --git a/apps/website/lib/app.module.ts b/apps/website/lib/app.module.ts new file mode 100644 index 000000000..5d7b9da0d --- /dev/null +++ b/apps/website/lib/app.module.ts @@ -0,0 +1,25 @@ +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/di-setup.ts b/apps/website/lib/di-setup.ts new file mode 100644 index 000000000..e80714c98 --- /dev/null +++ b/apps/website/lib/di-setup.ts @@ -0,0 +1,25 @@ +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/modules/analytics/AnalyticsModule.ts b/apps/website/lib/modules/analytics/AnalyticsModule.ts new file mode 100644 index 000000000..6dfbf8d7e --- /dev/null +++ b/apps/website/lib/modules/analytics/AnalyticsModule.ts @@ -0,0 +1,9 @@ +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 new file mode 100644 index 000000000..c62f13792 --- /dev/null +++ b/apps/website/lib/modules/analytics/AnalyticsProviders.ts @@ -0,0 +1,30 @@ +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 new file mode 100644 index 000000000..c8f9cf75c --- /dev/null +++ b/apps/website/lib/modules/auth/AuthModule.ts @@ -0,0 +1,9 @@ +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 new file mode 100644 index 000000000..35b1c4918 --- /dev/null +++ b/apps/website/lib/modules/auth/AuthProviders.ts @@ -0,0 +1,30 @@ +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 new file mode 100644 index 000000000..759344ca4 --- /dev/null +++ b/apps/website/lib/modules/driver/DriverModule.ts @@ -0,0 +1,9 @@ +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 new file mode 100644 index 000000000..d159871c4 --- /dev/null +++ b/apps/website/lib/modules/driver/DriverProviders.ts @@ -0,0 +1,22 @@ +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 new file mode 100644 index 000000000..7fe821308 --- /dev/null +++ b/apps/website/lib/modules/league/LeagueModule.ts @@ -0,0 +1,9 @@ +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 new file mode 100644 index 000000000..c13cc310b --- /dev/null +++ b/apps/website/lib/modules/league/LeagueProviders.ts @@ -0,0 +1,30 @@ +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 new file mode 100644 index 000000000..a025b6d50 --- /dev/null +++ b/apps/website/lib/modules/logging/LoggingModule.ts @@ -0,0 +1,17 @@ +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 new file mode 100644 index 000000000..28c02673a --- /dev/null +++ b/apps/website/lib/modules/media/MediaModule.ts @@ -0,0 +1,9 @@ +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 new file mode 100644 index 000000000..705603b27 --- /dev/null +++ b/apps/website/lib/modules/media/MediaProviders.ts @@ -0,0 +1,22 @@ +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 new file mode 100644 index 000000000..4e86c6dd2 --- /dev/null +++ b/apps/website/lib/modules/race/RaceModule.ts @@ -0,0 +1,9 @@ +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 new file mode 100644 index 000000000..de72cb8f9 --- /dev/null +++ b/apps/website/lib/modules/race/RaceProviders.ts @@ -0,0 +1,22 @@ +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 new file mode 100644 index 000000000..7719451dd --- /dev/null +++ b/apps/website/lib/modules/sponsor/SponsorModule.ts @@ -0,0 +1,9 @@ +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 new file mode 100644 index 000000000..9804c4b45 --- /dev/null +++ b/apps/website/lib/modules/sponsor/SponsorProviders.ts @@ -0,0 +1,22 @@ +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 new file mode 100644 index 000000000..09af74e5d --- /dev/null +++ b/apps/website/lib/modules/team/TeamModule.ts @@ -0,0 +1,9 @@ +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 new file mode 100644 index 000000000..9cf55b2e0 --- /dev/null +++ b/apps/website/lib/modules/team/TeamProviders.ts @@ -0,0 +1,22 @@ +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/LeagueSchedulePresenter.ts b/apps/website/lib/presenters/LeagueSchedulePresenter.ts index 518823df7..65563756e 100644 --- a/apps/website/lib/presenters/LeagueSchedulePresenter.ts +++ b/apps/website/lib/presenters/LeagueSchedulePresenter.ts @@ -1,10 +1,8 @@ import type { Race } from '@gridpilot/racing/domain/entities/Race'; -import { - getRaceRepository, - getIsDriverRegisteredForRaceQuery, - getRegisterForRaceUseCase, - getWithdrawFromRaceUseCase, -} from '@/lib/di-container'; +import type { IRaceRepository } from '@gridpilot/racing/application/ports/IRaceRepository'; +import type { IIsDriverRegisteredForRaceQuery } from '@gridpilot/racing/application/queries/IIsDriverRegisteredForRaceQuery'; +import type { IRegisterForRaceUseCase } from '@gridpilot/racing/application/use-cases/IRegisterForRaceUseCase'; +import type { IWithdrawFromRaceUseCase } from '@gridpilot/racing/application/use-cases/IWithdrawFromRaceUseCase'; export interface LeagueScheduleRaceItemViewModel { id: string; @@ -23,85 +21,95 @@ export interface LeagueScheduleViewModel { races: LeagueScheduleRaceItemViewModel[]; } -/** - * Load league schedule with registration status for a given driver. - */ -export async function loadLeagueSchedule( - leagueId: string, - driverId: string, -): Promise { - const raceRepo = getRaceRepository(); - const isRegisteredQuery = getIsDriverRegisteredForRaceQuery(); +export interface ILeagueSchedulePresenter { + loadLeagueSchedule(leagueId: string, driverId: string): Promise; + registerForRace(raceId: string, leagueId: string, driverId: string): Promise; + withdrawFromRace(raceId: string, driverId: string): Promise; +} - const allRaces = await raceRepo.findAll(); - const leagueRaces = allRaces - .filter((race) => race.leagueId === leagueId) - .sort( - (a, b) => - new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime(), +export class LeagueSchedulePresenter implements ILeagueSchedulePresenter { + constructor( + private raceRepository: IRaceRepository, + private isDriverRegisteredForRaceQuery: IIsDriverRegisteredForRaceQuery, + private registerForRaceUseCase: IRegisterForRaceUseCase, + private withdrawFromRaceUseCase: IWithdrawFromRaceUseCase, + ) {} + + /** + * Load league schedule with registration status for a given driver. + */ + async loadLeagueSchedule( + leagueId: string, + driverId: string, + ): Promise { + const allRaces = await this.raceRepository.findAll(); + const leagueRaces = allRaces + .filter((race) => race.leagueId === leagueId) + .sort( + (a, b) => + new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime(), + ); + + const now = new Date(); + + const registrationStates: Record = {}; + await Promise.all( + leagueRaces.map(async (race) => { + const registered = await this.isDriverRegisteredForRaceQuery.execute({ + raceId: race.id, + driverId, + }); + registrationStates[race.id] = registered; + }), ); - const now = new Date(); + const races: LeagueScheduleRaceItemViewModel[] = leagueRaces.map((race) => { + const raceDate = new Date(race.scheduledAt); + const isPast = race.status === 'completed' || raceDate <= now; + const isUpcoming = race.status === 'scheduled' && raceDate > now; - const registrationStates: Record = {}; - await Promise.all( - leagueRaces.map(async (race) => { - const registered = await isRegisteredQuery.execute({ - raceId: race.id, - driverId, - }); - registrationStates[race.id] = registered; - }), - ); + return { + id: race.id, + leagueId: race.leagueId, + track: race.track, + car: race.car, + sessionType: race.sessionType, + scheduledAt: raceDate, + status: race.status, + isUpcoming, + isPast, + isRegistered: registrationStates[race.id] ?? false, + }; + }); - const races: LeagueScheduleRaceItemViewModel[] = leagueRaces.map((race) => { - const raceDate = new Date(race.scheduledAt); - const isPast = race.status === 'completed' || raceDate <= now; - const isUpcoming = race.status === 'scheduled' && raceDate > now; + return { races }; + } - return { - id: race.id, - leagueId: race.leagueId, - track: race.track, - car: race.car, - sessionType: race.sessionType, - scheduledAt: raceDate, - status: race.status, - isUpcoming, - isPast, - isRegistered: registrationStates[race.id] ?? false, - }; - }); + /** + * Register the driver for a race. + */ + async registerForRace( + raceId: string, + leagueId: string, + driverId: string, + ): Promise { + await this.registerForRaceUseCase.execute({ + raceId, + leagueId, + driverId, + }); + } - return { races }; -} - -/** - * Register the driver for a race. - */ -export async function registerForRace( - raceId: string, - leagueId: string, - driverId: string, -): Promise { - const useCase = getRegisterForRaceUseCase(); - await useCase.execute({ - raceId, - leagueId, - driverId, - }); -} - -/** - * Withdraw the driver from a race. - */ -export async function withdrawFromRace( - raceId: string, - driverId: string, -): Promise { - const useCase = getWithdrawFromRaceUseCase(); - await useCase.execute({ - raceId, - driverId, - }); + /** + * Withdraw the driver from a race. + */ + async withdrawFromRace( + raceId: string, + driverId: string, + ): Promise { + await this.withdrawFromRaceUseCase.execute({ + raceId, + driverId, + }); + } } \ No newline at end of file diff --git a/apps/website/lib/services/LeagueMembershipService.ts b/apps/website/lib/services/LeagueMembershipService.ts new file mode 100644 index 000000000..e201dbeba --- /dev/null +++ b/apps/website/lib/services/LeagueMembershipService.ts @@ -0,0 +1,101 @@ +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/automation/application/ports/AutomationLifecycleEmitterPort.ts b/core/automation/application/ports/AutomationLifecycleEmitterPort.ts new file mode 100644 index 000000000..3758be76e --- /dev/null +++ b/core/automation/application/ports/AutomationLifecycleEmitterPort.ts @@ -0,0 +1,8 @@ +import { AutomationEvent } from './AutomationEventPublisherPort'; + +export type LifecycleCallback = (event: AutomationEvent) => Promise | void; + +export interface AutomationLifecycleEmitterPort { + onLifecycle(cb: LifecycleCallback): void; + offLifecycle(cb: LifecycleCallback): void; +} \ No newline at end of file diff --git a/core/automation/application/services/OverlaySyncService.ts b/core/automation/application/services/OverlaySyncService.ts index 2349f2f4f..4ff79184a 100644 --- a/core/automation/application/services/OverlaySyncService.ts +++ b/core/automation/application/services/OverlaySyncService.ts @@ -1,11 +1,11 @@ import { OverlaySyncPort, OverlayAction, ActionAck } from '../ports/OverlaySyncPort'; import { AutomationEventPublisherPort, AutomationEvent } from '../ports/AutomationEventPublisherPort'; -import { IAutomationLifecycleEmitter, LifecycleCallback } from '../../infrastructure/adapters/IAutomationLifecycleEmitter'; +import { AutomationLifecycleEmitterPort, LifecycleCallback } from '../ports/AutomationLifecycleEmitterPort'; import { LoggerPort } from '../ports/LoggerPort'; import type { IAsyncApplicationService } from '@gridpilot/shared/application'; type ConstructorArgs = { - lifecycleEmitter: IAutomationLifecycleEmitter + lifecycleEmitter: AutomationLifecycleEmitterPort publisher: AutomationEventPublisherPort logger: LoggerPort initialPanelWaitMs?: number @@ -17,7 +17,7 @@ type ConstructorArgs = { export class OverlaySyncService implements OverlaySyncPort, IAsyncApplicationService { - private lifecycleEmitter: IAutomationLifecycleEmitter + private lifecycleEmitter: AutomationLifecycleEmitterPort private publisher: AutomationEventPublisherPort private logger: LoggerPort private initialPanelWaitMs: number diff --git a/core/racing/application/ports/LeagueScoringPresetProvider.ts b/core/racing/application/ports/LeagueScoringPresetProvider.ts index 2e18ac4b3..3b9f4a674 100644 --- a/core/racing/application/ports/LeagueScoringPresetProvider.ts +++ b/core/racing/application/ports/LeagueScoringPresetProvider.ts @@ -1,3 +1,5 @@ +import type { LeagueScoringConfig } from '../../domain/entities/LeagueScoringConfig'; + export type LeagueScoringPresetPrimaryChampionshipType = | 'driver' | 'team' @@ -23,4 +25,5 @@ export interface LeagueScoringPresetDTO { export interface LeagueScoringPresetProvider { listPresets(): LeagueScoringPresetDTO[]; getPresetById(id: string): LeagueScoringPresetDTO | undefined; + createScoringConfigFromPreset(presetId: string, seasonId: string): LeagueScoringConfig; } \ No newline at end of file diff --git a/core/racing/application/presenters/IApproveLeagueJoinRequestPresenter.ts b/core/racing/application/presenters/IApproveLeagueJoinRequestPresenter.ts new file mode 100644 index 000000000..959987d0c --- /dev/null +++ b/core/racing/application/presenters/IApproveLeagueJoinRequestPresenter.ts @@ -0,0 +1,13 @@ +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; + +export interface ApproveLeagueJoinRequestViewModel { + success: boolean; + message: string; +} + +export interface ApproveLeagueJoinRequestResultDTO { + success: boolean; + message: string; +} + +export interface IApproveLeagueJoinRequestPresenter extends Presenter {} \ No newline at end of file diff --git a/core/racing/application/presenters/ICompleteDriverOnboardingPresenter.ts b/core/racing/application/presenters/ICompleteDriverOnboardingPresenter.ts new file mode 100644 index 000000000..92eea9e6d --- /dev/null +++ b/core/racing/application/presenters/ICompleteDriverOnboardingPresenter.ts @@ -0,0 +1,17 @@ +export interface CompleteDriverOnboardingViewModel { + success: boolean; + driverId?: string; + errorMessage?: string; +} + +export interface CompleteDriverOnboardingResultDTO { + success: boolean; + driverId?: string; + errorMessage?: string; +} + +export interface ICompleteDriverOnboardingPresenter { + present(dto: CompleteDriverOnboardingResultDTO): void; + get viewModel(): CompleteDriverOnboardingViewModel; + reset(): void; +} \ No newline at end of file diff --git a/core/racing/application/presenters/IGetLeagueAdminPermissionsPresenter.ts b/core/racing/application/presenters/IGetLeagueAdminPermissionsPresenter.ts new file mode 100644 index 000000000..36ada28a2 --- /dev/null +++ b/core/racing/application/presenters/IGetLeagueAdminPermissionsPresenter.ts @@ -0,0 +1,13 @@ +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; + +export interface GetLeagueAdminPermissionsViewModel { + canRemoveMember: boolean; + canUpdateRoles: boolean; +} + +export interface GetLeagueAdminPermissionsResultDTO { + canRemoveMember: boolean; + canUpdateRoles: boolean; +} + +export interface IGetLeagueAdminPermissionsPresenter extends Presenter {} \ No newline at end of file diff --git a/core/racing/application/presenters/IGetLeagueAdminPresenter.ts b/core/racing/application/presenters/IGetLeagueAdminPresenter.ts new file mode 100644 index 000000000..38af60684 --- /dev/null +++ b/core/racing/application/presenters/IGetLeagueAdminPresenter.ts @@ -0,0 +1,13 @@ +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; + +export interface LeagueAdminViewModel { + leagueId: string; + ownerId: string; +} + +export interface GetLeagueAdminResultDTO { + leagueId: string; + ownerId: string; +} + +export interface IGetLeagueAdminPresenter extends Presenter {} \ No newline at end of file diff --git a/core/racing/application/presenters/IGetLeagueJoinRequestsPresenter.ts b/core/racing/application/presenters/IGetLeagueJoinRequestsPresenter.ts new file mode 100644 index 000000000..ed9398812 --- /dev/null +++ b/core/racing/application/presenters/IGetLeagueJoinRequestsPresenter.ts @@ -0,0 +1,21 @@ +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; + +export interface LeagueJoinRequestViewModel { + id: string; + leagueId: string; + driverId: string; + requestedAt: Date; + message: string; + driver: { id: string; name: string } | null; +} + +export interface GetLeagueJoinRequestsViewModel { + joinRequests: LeagueJoinRequestViewModel[]; +} + +export interface GetLeagueJoinRequestsResultDTO { + joinRequests: any[]; + drivers: { id: string; name: string }[]; +} + +export interface IGetLeagueJoinRequestsPresenter extends Presenter {} \ No newline at end of file diff --git a/core/racing/application/presenters/IGetLeagueMembershipsPresenter.ts b/core/racing/application/presenters/IGetLeagueMembershipsPresenter.ts new file mode 100644 index 000000000..b4cd5f680 --- /dev/null +++ b/core/racing/application/presenters/IGetLeagueMembershipsPresenter.ts @@ -0,0 +1,21 @@ +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; + +export interface LeagueMembershipsViewModel { + members: { + driverId: string; + driver: { id: string; name: string }; + role: string; + joinedAt: Date; + }[]; +} + +export interface GetLeagueMembershipsViewModel { + memberships: LeagueMembershipsViewModel; +} + +export interface GetLeagueMembershipsResultDTO { + memberships: any[]; + drivers: { id: string; name: string }[]; +} + +export interface IGetLeagueMembershipsPresenter extends Presenter {} \ No newline at end of file diff --git a/core/racing/application/presenters/IGetLeagueOwnerSummaryPresenter.ts b/core/racing/application/presenters/IGetLeagueOwnerSummaryPresenter.ts new file mode 100644 index 000000000..3eef4ec02 --- /dev/null +++ b/core/racing/application/presenters/IGetLeagueOwnerSummaryPresenter.ts @@ -0,0 +1,17 @@ +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; + +export interface LeagueOwnerSummaryViewModel { + driver: { id: string; name: string }; + rating: number; + rank: number; +} + +export interface GetLeagueOwnerSummaryViewModel { + summary: LeagueOwnerSummaryViewModel | null; +} + +export interface GetLeagueOwnerSummaryResultDTO { + summary: LeagueOwnerSummaryViewModel | null; +} + +export interface IGetLeagueOwnerSummaryPresenter extends Presenter {} \ No newline at end of file diff --git a/core/racing/application/presenters/IGetLeagueProtestsPresenter.ts b/core/racing/application/presenters/IGetLeagueProtestsPresenter.ts new file mode 100644 index 000000000..5baedb094 --- /dev/null +++ b/core/racing/application/presenters/IGetLeagueProtestsPresenter.ts @@ -0,0 +1,15 @@ +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; + +export interface GetLeagueProtestsViewModel { + protests: any[]; + racesById: Record; + driversById: Record; +} + +export interface GetLeagueProtestsResultDTO { + protests: any[]; + races: any[]; + drivers: { id: string; name: string }[]; +} + +export interface IGetLeagueProtestsPresenter extends Presenter {} \ No newline at end of file diff --git a/core/racing/application/presenters/IGetLeagueSchedulePresenter.ts b/core/racing/application/presenters/IGetLeagueSchedulePresenter.ts new file mode 100644 index 000000000..ed94ea7b7 --- /dev/null +++ b/core/racing/application/presenters/IGetLeagueSchedulePresenter.ts @@ -0,0 +1,19 @@ +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; + +export interface LeagueScheduleViewModel { + races: Array<{ + id: string; + name: string; + date: string; + }>; +} + +export interface GetLeagueScheduleResultDTO { + races: Array<{ + id: string; + name: string; + scheduledAt: Date; + }>; +} + +export interface IGetLeagueSchedulePresenter extends Presenter {} \ No newline at end of file diff --git a/core/racing/application/presenters/IGetLeagueSeasonsPresenter.ts b/core/racing/application/presenters/IGetLeagueSeasonsPresenter.ts new file mode 100644 index 000000000..a5073ff93 --- /dev/null +++ b/core/racing/application/presenters/IGetLeagueSeasonsPresenter.ts @@ -0,0 +1,21 @@ +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; + +export interface LeagueSeasonSummaryViewModel { + seasonId: string; + name: string; + status: string; + startDate: Date; + endDate: Date; + isPrimary: boolean; + isParallelActive: boolean; +} + +export interface GetLeagueSeasonsViewModel { + seasons: LeagueSeasonSummaryViewModel[]; +} + +export interface GetLeagueSeasonsResultDTO { + seasons: any[]; +} + +export interface IGetLeagueSeasonsPresenter extends Presenter {} \ No newline at end of file diff --git a/core/racing/application/presenters/IGetTotalLeaguesPresenter.ts b/core/racing/application/presenters/IGetTotalLeaguesPresenter.ts new file mode 100644 index 000000000..36d82ccf2 --- /dev/null +++ b/core/racing/application/presenters/IGetTotalLeaguesPresenter.ts @@ -0,0 +1,11 @@ +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; + +export interface GetTotalLeaguesViewModel { + totalLeagues: number; +} + +export interface GetTotalLeaguesResultDTO { + totalLeagues: number; +} + +export interface IGetTotalLeaguesPresenter extends Presenter {} \ No newline at end of file diff --git a/core/racing/application/presenters/ILeagueStandingsPresenter.ts b/core/racing/application/presenters/ILeagueStandingsPresenter.ts index 1881d1799..b37f6c8b3 100644 --- a/core/racing/application/presenters/ILeagueStandingsPresenter.ts +++ b/core/racing/application/presenters/ILeagueStandingsPresenter.ts @@ -2,24 +2,19 @@ import type { Standing } from '../../domain/entities/Standing'; import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; export interface StandingItemViewModel { - id: string; - leagueId: string; - seasonId: string; driverId: string; - position: number; + driver: { id: string; name: string }; points: number; - wins: number; - podiums: number; - racesCompleted: number; + rank: number; } export interface LeagueStandingsViewModel { - leagueId: string; standings: StandingItemViewModel[]; } export interface LeagueStandingsResultDTO { standings: Standing[]; + drivers: { id: string; name: string }[]; } export interface ILeagueStandingsPresenter diff --git a/core/racing/application/presenters/ILeagueStatsPresenter.ts b/core/racing/application/presenters/ILeagueStatsPresenter.ts index 3a994f683..0f74107b1 100644 --- a/core/racing/application/presenters/ILeagueStatsPresenter.ts +++ b/core/racing/application/presenters/ILeagueStatsPresenter.ts @@ -1,20 +1,16 @@ +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; + export interface LeagueStatsViewModel { - leagueId: string; + totalMembers: number; totalRaces: number; - completedRaces: number; - scheduledRaces: number; - averageSOF: number | null; - highestSOF: number | null; - lowestSOF: number | null; + averageRating: number; } -export interface ILeagueStatsPresenter { - present( - leagueId: string, - totalRaces: number, - completedRaces: number, - scheduledRaces: number, - sofValues: number[] - ): LeagueStatsViewModel; - getViewModel(): LeagueStatsViewModel; -} \ No newline at end of file +export interface LeagueStatsResultDTO { + totalMembers: number; + totalRaces: number; + averageRating: number; +} + +export interface ILeagueStatsPresenter + extends Presenter {} \ No newline at end of file diff --git a/core/racing/application/presenters/IRejectLeagueJoinRequestPresenter.ts b/core/racing/application/presenters/IRejectLeagueJoinRequestPresenter.ts new file mode 100644 index 000000000..ef1f180fd --- /dev/null +++ b/core/racing/application/presenters/IRejectLeagueJoinRequestPresenter.ts @@ -0,0 +1,13 @@ +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; + +export interface RejectLeagueJoinRequestViewModel { + success: boolean; + message: string; +} + +export interface RejectLeagueJoinRequestResultDTO { + success: boolean; + message: string; +} + +export interface IRejectLeagueJoinRequestPresenter extends Presenter {} \ No newline at end of file diff --git a/core/racing/application/presenters/IRemoveLeagueMemberPresenter.ts b/core/racing/application/presenters/IRemoveLeagueMemberPresenter.ts new file mode 100644 index 000000000..9ec6de653 --- /dev/null +++ b/core/racing/application/presenters/IRemoveLeagueMemberPresenter.ts @@ -0,0 +1,11 @@ +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; + +export interface RemoveLeagueMemberViewModel { + success: boolean; +} + +export interface RemoveLeagueMemberResultDTO { + success: boolean; +} + +export interface IRemoveLeagueMemberPresenter extends Presenter {} \ No newline at end of file diff --git a/core/racing/application/presenters/ITotalDriversPresenter.ts b/core/racing/application/presenters/ITotalDriversPresenter.ts new file mode 100644 index 000000000..5263de7a9 --- /dev/null +++ b/core/racing/application/presenters/ITotalDriversPresenter.ts @@ -0,0 +1,13 @@ +export interface TotalDriversViewModel { + totalDrivers: number; +} + +export interface TotalDriversResultDTO { + totalDrivers: number; +} + +export interface ITotalDriversPresenter { + present(dto: TotalDriversResultDTO): void; + get viewModel(): TotalDriversViewModel; + reset(): void; +} \ No newline at end of file diff --git a/core/racing/application/presenters/IUpdateLeagueMemberRolePresenter.ts b/core/racing/application/presenters/IUpdateLeagueMemberRolePresenter.ts new file mode 100644 index 000000000..3469b5d61 --- /dev/null +++ b/core/racing/application/presenters/IUpdateLeagueMemberRolePresenter.ts @@ -0,0 +1,11 @@ +import type { Presenter } from '@gridpilot/shared/presentation/Presenter'; + +export interface UpdateLeagueMemberRoleViewModel { + success: boolean; +} + +export interface UpdateLeagueMemberRoleResultDTO { + success: boolean; +} + +export interface IUpdateLeagueMemberRolePresenter extends Presenter {} \ No newline at end of file diff --git a/core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase.ts b/core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase.ts new file mode 100644 index 000000000..101f02f0a --- /dev/null +++ b/core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase.ts @@ -0,0 +1,36 @@ +import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import type { IApproveLeagueJoinRequestPresenter, ApproveLeagueJoinRequestResultDTO, ApproveLeagueJoinRequestViewModel } from '../presenters/IApproveLeagueJoinRequestPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export interface ApproveLeagueJoinRequestUseCaseParams { + leagueId: string; + requestId: string; +} + +export interface ApproveLeagueJoinRequestResultDTO { + success: boolean; + message: string; +} + +export class ApproveLeagueJoinRequestUseCase implements UseCase { + constructor(private readonly leagueMembershipRepository: ILeagueMembershipRepository) {} + + async execute(params: ApproveLeagueJoinRequestUseCaseParams, presenter: IApproveLeagueJoinRequestPresenter): Promise { + const requests = await this.leagueMembershipRepository.getJoinRequests(params.leagueId); + const request = requests.find(r => r.id === params.requestId); + if (!request) { + throw new Error('Join request not found'); + } + await this.leagueMembershipRepository.removeJoinRequest(params.requestId); + await this.leagueMembershipRepository.saveMembership({ + leagueId: params.leagueId, + driverId: request.driverId, + role: 'member', + status: 'active', + joinedAt: new Date(), + }); + const dto: ApproveLeagueJoinRequestResultDTO = { success: true, message: 'Join request approved.' }; + presenter.reset(); + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.ts b/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.ts new file mode 100644 index 000000000..72ac0de58 --- /dev/null +++ b/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.ts @@ -0,0 +1,60 @@ +import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import type { ICompleteDriverOnboardingPresenter, CompleteDriverOnboardingResultDTO } from '../presenters/ICompleteDriverOnboardingPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; +import { Driver } from '../../domain/entities/Driver'; + +export interface CompleteDriverOnboardingInput { + userId: string; + firstName: string; + lastName: string; + displayName: string; + country: string; + timezone?: string; + bio?: string; +} + +/** + * Use Case for completing driver onboarding. + */ +export class CompleteDriverOnboardingUseCase + implements UseCase +{ + constructor(private readonly driverRepository: IDriverRepository) {} + + async execute(input: CompleteDriverOnboardingInput, presenter: ICompleteDriverOnboardingPresenter): Promise { + presenter.reset(); + + try { + // Check if driver already exists + const existing = await this.driverRepository.findById(input.userId); + if (existing) { + presenter.present({ + success: false, + errorMessage: 'Driver already exists', + }); + return; + } + + // Create new driver + const driver = Driver.create({ + id: input.userId, + iracingId: input.userId, // Assuming userId is iracingId for now + name: input.displayName, + country: input.country, + bio: input.bio, + }); + + await this.driverRepository.save(driver); + + presenter.present({ + success: true, + driverId: driver.id, + }); + } catch (error) { + presenter.present({ + success: false, + errorMessage: error instanceof Error ? error.message : 'Unknown error', + }); + } + } +} \ No newline at end of file diff --git a/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts b/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts index 108c349c7..76e213b5c 100644 --- a/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts +++ b/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts @@ -57,6 +57,7 @@ export class CreateLeagueWithSeasonAndScoringUseCase private readonly seasonRepository: ISeasonRepository, private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository, private readonly presetProvider: LeagueScoringPresetProvider, + private readonly logger: Logger, ) {} async execute( @@ -113,31 +114,8 @@ export class CreateLeagueWithSeasonAndScoringUseCase this.logger.info(`Scoring preset ${preset.name} (${preset.id}) retrieved.`); - const scoringConfig: LeagueScoringConfig = { - id: uuidv4(), - seasonId, - scoringPresetId: preset.id, - championships: [], - }; - - const fullConfigFactory = (await import( - '../../infrastructure/repositories/InMemoryScoringRepositories' - )) as typeof import('../../infrastructure/repositories/InMemoryScoringRepositories'); - - const presetFromInfra = fullConfigFactory.getLeagueScoringPresetById( - preset.id, - ); - if (!presetFromInfra) { - this.logger.error(`Preset registry missing preset: ${preset.id}`); - throw new Error(`Preset registry missing preset: ${preset.id}`); - } - this.logger.debug(`Preset from infrastructure retrieved for ${preset.id}.`); - - const infraConfig = presetFromInfra.createConfig({ seasonId }); - const finalConfig: LeagueScoringConfig = { - ...infraConfig, - scoringPresetId: preset.id, - }; + const finalConfig = this.presetProvider.createScoringConfigFromPreset(preset.id, seasonId); + this.logger.debug(`Scoring configuration created from preset ${preset.id}.`); await this.leagueScoringConfigRepository.save(finalConfig); this.logger.info(`Scoring configuration saved for season ${seasonId}.`); diff --git a/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase.ts b/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase.ts new file mode 100644 index 000000000..b7e49e3fe --- /dev/null +++ b/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase.ts @@ -0,0 +1,42 @@ +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import type { IGetLeagueAdminPermissionsPresenter, GetLeagueAdminPermissionsResultDTO, GetLeagueAdminPermissionsViewModel } from '../presenters/IGetLeagueAdminPermissionsPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export interface GetLeagueAdminPermissionsUseCaseParams { + leagueId: string; + performerDriverId: string; +} + +export interface GetLeagueAdminPermissionsResultDTO { + canRemoveMember: boolean; + canUpdateRoles: boolean; +} + +export class GetLeagueAdminPermissionsUseCase implements UseCase { + constructor( + private readonly leagueRepository: ILeagueRepository, + private readonly leagueMembershipRepository: ILeagueMembershipRepository, + ) {} + + async execute(params: GetLeagueAdminPermissionsUseCaseParams, presenter: IGetLeagueAdminPermissionsPresenter): Promise { + const league = await this.leagueRepository.findById(params.leagueId); + if (!league) { + presenter.present({ canRemoveMember: false, canUpdateRoles: false }); + return; + } + + const membership = await this.leagueMembershipRepository.getMembership(params.leagueId, params.performerDriverId); + if (!membership || membership.status !== 'active') { + presenter.present({ canRemoveMember: false, canUpdateRoles: false }); + return; + } + + // Business logic: owners and admins can remove members and update roles + const canRemoveMember = membership.role === 'owner' || membership.role === 'admin'; + const canUpdateRoles = membership.role === 'owner' || membership.role === 'admin'; + + presenter.reset(); + presenter.present({ canRemoveMember, canUpdateRoles }); + } +} \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueJoinRequestsUseCase.ts b/core/racing/application/use-cases/GetLeagueJoinRequestsUseCase.ts new file mode 100644 index 000000000..6e11f533d --- /dev/null +++ b/core/racing/application/use-cases/GetLeagueJoinRequestsUseCase.ts @@ -0,0 +1,33 @@ +import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import type { IGetLeagueJoinRequestsPresenter, GetLeagueJoinRequestsResultDTO, GetLeagueJoinRequestsViewModel } from '../presenters/IGetLeagueJoinRequestsPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export interface GetLeagueJoinRequestsUseCaseParams { + leagueId: string; +} + +export interface GetLeagueJoinRequestsResultDTO { + joinRequests: any[]; + drivers: { id: string; name: string }[]; +} + +export class GetLeagueJoinRequestsUseCase implements UseCase { + constructor( + private readonly leagueMembershipRepository: ILeagueMembershipRepository, + private readonly driverRepository: IDriverRepository, + ) {} + + async execute(params: GetLeagueJoinRequestsUseCaseParams, presenter: IGetLeagueJoinRequestsPresenter): Promise { + const joinRequests = await this.leagueMembershipRepository.getJoinRequests(params.leagueId); + const driverIds = joinRequests.map(r => r.driverId); + const drivers = await this.driverRepository.findByIds(driverIds); + const driverMap = new Map(drivers.map(d => [d.id, { id: d.id, name: d.name }])); + const dto: GetLeagueJoinRequestsResultDTO = { + joinRequests, + drivers: Array.from(driverMap.values()), + }; + presenter.reset(); + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueMembershipsUseCase.ts b/core/racing/application/use-cases/GetLeagueMembershipsUseCase.ts new file mode 100644 index 000000000..ad02a9137 --- /dev/null +++ b/core/racing/application/use-cases/GetLeagueMembershipsUseCase.ts @@ -0,0 +1,41 @@ +import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import type { LeagueMembership } from '../../domain/entities/LeagueMembership'; +import type { IGetLeagueMembershipsPresenter, GetLeagueMembershipsViewModel } from '../presenters/IGetLeagueMembershipsPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export interface GetLeagueMembershipsUseCaseParams { + leagueId: string; +} + +export interface GetLeagueMembershipsResultDTO { + memberships: LeagueMembership[]; + drivers: { id: string; name: string }[]; +} + +export class GetLeagueMembershipsUseCase implements UseCase { + constructor( + private readonly leagueMembershipRepository: ILeagueMembershipRepository, + private readonly driverRepository: IDriverRepository, + ) {} + + async execute(params: GetLeagueMembershipsUseCaseParams, presenter: IGetLeagueMembershipsPresenter): Promise { + const memberships = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId); + const drivers: { id: string; name: string }[] = []; + + // Get driver details for each membership + for (const membership of memberships) { + const driver = await this.driverRepository.findById(membership.driverId); + if (driver) { + drivers.push({ id: driver.id, name: driver.name }); + } + } + + const dto: GetLeagueMembershipsResultDTO = { + memberships, + drivers, + }; + presenter.reset(); + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase.ts b/core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase.ts new file mode 100644 index 000000000..2a6ca0c0d --- /dev/null +++ b/core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase.ts @@ -0,0 +1,23 @@ +import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import type { IGetLeagueOwnerSummaryPresenter, GetLeagueOwnerSummaryResultDTO, GetLeagueOwnerSummaryViewModel } from '../presenters/IGetLeagueOwnerSummaryPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export interface GetLeagueOwnerSummaryUseCaseParams { + ownerId: string; +} + +export interface GetLeagueOwnerSummaryResultDTO { + summary: { driver: { id: string; name: string }; rating: number; rank: number } | null; +} + +export class GetLeagueOwnerSummaryUseCase implements UseCase { + constructor(private readonly driverRepository: IDriverRepository) {} + + async execute(params: GetLeagueOwnerSummaryUseCaseParams, presenter: IGetLeagueOwnerSummaryPresenter): Promise { + const driver = await this.driverRepository.findById(params.ownerId); + const summary = driver ? { driver: { id: driver.id, name: driver.name }, rating: 0, rank: 0 } : null; + const dto: GetLeagueOwnerSummaryResultDTO = { summary }; + presenter.reset(); + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueProtestsUseCase.ts b/core/racing/application/use-cases/GetLeagueProtestsUseCase.ts new file mode 100644 index 000000000..1dd10306d --- /dev/null +++ b/core/racing/application/use-cases/GetLeagueProtestsUseCase.ts @@ -0,0 +1,58 @@ +import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import type { IProtestRepository } from '../../domain/repositories/IProtestRepository'; +import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import type { IGetLeagueProtestsPresenter, GetLeagueProtestsResultDTO, GetLeagueProtestsViewModel } from '../presenters/IGetLeagueProtestsPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export interface GetLeagueProtestsUseCaseParams { + leagueId: string; +} + +export interface GetLeagueProtestsResultDTO { + protests: any[]; + races: any[]; + drivers: { id: string; name: string }[]; +} + +export class GetLeagueProtestsUseCase implements UseCase { + constructor( + private readonly raceRepository: IRaceRepository, + private readonly protestRepository: IProtestRepository, + private readonly driverRepository: IDriverRepository, + ) {} + + async execute(params: GetLeagueProtestsUseCaseParams, presenter: IGetLeagueProtestsPresenter): Promise { + const races = await this.raceRepository.findByLeagueId(params.leagueId); + const protests = []; + const raceMap = new Map(); + const driverIds = new Set(); + + for (const race of races) { + raceMap.set(race.id, { id: race.id, name: race.name, date: race.scheduledAt.toISOString() }); + const raceProtests = await this.protestRepository.findByRaceId(race.id); + for (const protest of raceProtests) { + protests.push({ + id: protest.id, + raceId: protest.raceId, + protestingDriverId: protest.protestingDriverId, + accusedDriverId: protest.accusedDriverId, + submittedAt: protest.filedAt, + description: protest.comment || '', + status: protest.status, + }); + driverIds.add(protest.protestingDriverId); + driverIds.add(protest.accusedDriverId); + } + } + + const drivers = await this.driverRepository.findByIds(Array.from(driverIds)); + const driverMap = new Map(drivers.map(d => [d.id, { id: d.id, name: d.name }])); + const dto: GetLeagueProtestsResultDTO = { + protests, + races: Array.from(raceMap.values()), + drivers: Array.from(driverMap.values()), + }; + presenter.reset(); + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueScheduleUseCase.ts b/core/racing/application/use-cases/GetLeagueScheduleUseCase.ts new file mode 100644 index 000000000..2f867508f --- /dev/null +++ b/core/racing/application/use-cases/GetLeagueScheduleUseCase.ts @@ -0,0 +1,32 @@ +import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import type { IGetLeagueSchedulePresenter, GetLeagueScheduleResultDTO, GetLeagueScheduleViewModel } from '../presenters/IGetLeagueSchedulePresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export interface GetLeagueScheduleUseCaseParams { + leagueId: string; +} + +export interface GetLeagueScheduleResultDTO { + races: Array<{ + id: string; + name: string; + scheduledAt: Date; + }>; +} + +export class GetLeagueScheduleUseCase implements UseCase { + constructor(private readonly raceRepository: IRaceRepository) {} + + async execute(params: GetLeagueScheduleUseCaseParams, presenter: IGetLeagueSchedulePresenter): Promise { + const races = await this.raceRepository.findByLeagueId(params.leagueId); + const dto: GetLeagueScheduleResultDTO = { + races: races.map(race => ({ + id: race.id, + name: `${race.track} - ${race.car}`, + scheduledAt: race.scheduledAt, + })), + }; + presenter.reset(); + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueSeasonsUseCase.ts b/core/racing/application/use-cases/GetLeagueSeasonsUseCase.ts new file mode 100644 index 000000000..78f113001 --- /dev/null +++ b/core/racing/application/use-cases/GetLeagueSeasonsUseCase.ts @@ -0,0 +1,22 @@ +import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; +import type { IGetLeagueSeasonsPresenter, GetLeagueSeasonsResultDTO, GetLeagueSeasonsViewModel } from '../presenters/IGetLeagueSeasonsPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export interface GetLeagueSeasonsUseCaseParams { + leagueId: string; +} + +export interface GetLeagueSeasonsResultDTO { + seasons: any[]; +} + +export class GetLeagueSeasonsUseCase implements UseCase { + constructor(private readonly seasonRepository: ISeasonRepository) {} + + async execute(params: GetLeagueSeasonsUseCaseParams, presenter: IGetLeagueSeasonsPresenter): Promise { + const seasons = await this.seasonRepository.findByLeagueId(params.leagueId); + const dto: GetLeagueSeasonsResultDTO = { seasons }; + presenter.reset(); + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueStandingsUseCase.ts b/core/racing/application/use-cases/GetLeagueStandingsUseCase.ts index 11708e209..c026e8073 100644 --- a/core/racing/application/use-cases/GetLeagueStandingsUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueStandingsUseCase.ts @@ -1,4 +1,5 @@ import type { IStandingRepository } from '../../domain/repositories/IStandingRepository'; +import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import type { ILeagueStandingsPresenter, LeagueStandingsResultDTO, @@ -18,15 +19,22 @@ export class GetLeagueStandingsUseCase implements UseCase { - constructor(private readonly standingRepository: IStandingRepository) {} + constructor( + private readonly standingRepository: IStandingRepository, + private readonly driverRepository: IDriverRepository, + ) {} async execute( params: GetLeagueStandingsUseCaseParams, presenter: ILeagueStandingsPresenter, ): Promise { const standings = await this.standingRepository.findByLeagueId(params.leagueId); + const driverIds = [...new Set(standings.map(s => s.driverId))]; + const drivers = await this.driverRepository.findByIds(driverIds); + const driverMap = new Map(drivers.map(d => [d.id, { id: d.id, name: d.name }])); const dto: LeagueStandingsResultDTO = { standings, + drivers: Array.from(driverMap.values()), }; presenter.reset(); presenter.present(dto); diff --git a/core/racing/application/use-cases/GetLeagueStatsUseCase.ts b/core/racing/application/use-cases/GetLeagueStatsUseCase.ts index f8614f8d8..87dd511f1 100644 --- a/core/racing/application/use-cases/GetLeagueStatsUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueStatsUseCase.ts @@ -1,109 +1,28 @@ -/** - * Use Case for retrieving league statistics. - * Orchestrates domain logic and delegates presentation to the presenter. - */ - -import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; -import type { IResultRepository } from '../../domain/repositories/IRaceRepository'; -import type { DriverRatingProvider } from '../ports/DriverRatingProvider'; -import type { ILeagueStatsPresenter } from '../presenters/ILeagueStatsPresenter'; -import type { AsyncUseCase } from '@gridpilot/shared/application'; -import { Logger } from "@gridpilot/core/shared/application"; -import { - AverageStrengthOfFieldCalculator, - type StrengthOfFieldCalculator, -} from '../../domain/services/StrengthOfFieldCalculator'; +import type { ILeagueStatsPresenter, LeagueStatsResultDTO, LeagueStatsViewModel } from '../presenters/ILeagueStatsPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; export interface GetLeagueStatsUseCaseParams { leagueId: string; } -/** - * Use Case for retrieving league statistics including average SOF across completed races. - */ -export class GetLeagueStatsUseCase - implements AsyncUseCase { - private readonly sofCalculator: StrengthOfFieldCalculator; - +export class GetLeagueStatsUseCase implements UseCase { constructor( - private readonly leagueRepository: ILeagueRepository, + private readonly leagueMembershipRepository: ILeagueMembershipRepository, private readonly raceRepository: IRaceRepository, - private readonly resultRepository: IResultRepository, - private readonly driverRatingProvider: DriverRatingProvider, - public readonly presenter: ILeagueStatsPresenter, - private readonly logger: Logger, - sofCalculator?: StrengthOfFieldCalculator, - ) { - this.sofCalculator = sofCalculator ?? new AverageStrengthOfFieldCalculator(); - } + ) {} - async execute(params: GetLeagueStatsUseCaseParams): Promise { - this.logger.debug( - `Executing GetLeagueStatsUseCase with params: ${JSON.stringify(params)}`, - ); - const { leagueId } = params; - - try { - const league = await this.leagueRepository.findById(leagueId); - if (!league) { - this.logger.error(`League ${leagueId} not found`); - throw new Error(`League ${leagueId} not found`); - } - - const races = await this.raceRepository.findByLeagueId(leagueId); - const completedRaces = races.filter(r => r.status === 'completed'); - const scheduledRaces = races.filter(r => r.status === 'scheduled'); - this.logger.info( - `Found ${races.length} races for league ${leagueId}: ${completedRaces.length} completed, ${scheduledRaces.length} scheduled. `, - ); - - // Calculate SOF for each completed race - const sofValues: number[] = []; - - for (const race of completedRaces) { - // Use stored SOF if available - if (race.strengthOfField) { - this.logger.debug( - `Using stored Strength of Field for race ${race.id}: ${race.strengthOfField}`, - ); - sofValues.push(race.strengthOfField); - continue; - } - - // Otherwise calculate from results - const results = await this.resultRepository.findByRaceId(race.id); - if (results.length === 0) { - this.logger.debug(`No results found for race ${race.id}. Skipping SOF calculation.`); - continue; - } - - const driverIds = results.map(r => r.driverId); - const ratings = this.driverRatingProvider.getRatings(driverIds); - const driverRatings = driverIds - .filter(id => ratings.has(id)) - .map(id => ({ driverId: id, rating: ratings.get(id)! })); - - const sof = this.sofCalculator.calculate(driverRatings); - if (sof !== null) { - this.logger.debug(`Calculated Strength of Field for race ${race.id}: ${sof}`); - sofValues.push(sof); - } else { - this.logger.warn(`Could not calculate Strength of Field for race ${race.id}`); - } - } - - this.presenter.present( - leagueId, - races.length, - completedRaces.length, - scheduledRaces.length, - sofValues, - ); - this.logger.info(`Successfully presented league statistics for league ${leagueId}.`); - } catch (error) { - this.logger.error(`Error in GetLeagueStatsUseCase: ${error.message}`); - throw error; - } + async execute(params: GetLeagueStatsUseCaseParams, presenter: ILeagueStatsPresenter): Promise { + const memberships = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId); + const races = await this.raceRepository.findByLeagueId(params.leagueId); + // TODO: Implement average rating calculation from driver ratings + const dto: LeagueStatsResultDTO = { + totalMembers: memberships.length, + totalRaces: races.length, + averageRating: 0, + }; + presenter.reset(); + presenter.present(dto); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetTotalDriversUseCase.ts b/core/racing/application/use-cases/GetTotalDriversUseCase.ts new file mode 100644 index 000000000..863db0cb0 --- /dev/null +++ b/core/racing/application/use-cases/GetTotalDriversUseCase.ts @@ -0,0 +1,23 @@ +import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import type { ITotalDriversPresenter, TotalDriversResultDTO } from '../presenters/ITotalDriversPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +/** + * Use Case for retrieving total number of drivers. + */ +export class GetTotalDriversUseCase + implements UseCase +{ + constructor(private readonly driverRepository: IDriverRepository) {} + + async execute(_input: void, presenter: ITotalDriversPresenter): Promise { + presenter.reset(); + + const drivers = await this.driverRepository.findAll(); + const dto: TotalDriversResultDTO = { + totalDrivers: drivers.length, + }; + + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/racing/application/use-cases/GetTotalLeaguesUseCase.ts b/core/racing/application/use-cases/GetTotalLeaguesUseCase.ts new file mode 100644 index 000000000..9506bd19c --- /dev/null +++ b/core/racing/application/use-cases/GetTotalLeaguesUseCase.ts @@ -0,0 +1,20 @@ +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { IGetTotalLeaguesPresenter, GetTotalLeaguesResultDTO, GetTotalLeaguesViewModel } from '../presenters/IGetTotalLeaguesPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export interface GetTotalLeaguesUseCaseParams {} + +export interface GetTotalLeaguesResultDTO { + totalLeagues: number; +} + +export class GetTotalLeaguesUseCase implements UseCase { + constructor(private readonly leagueRepository: ILeagueRepository) {} + + async execute(params: GetTotalLeaguesUseCaseParams, presenter: IGetTotalLeaguesPresenter): Promise { + const leagues = await this.leagueRepository.findAll(); + const dto: GetTotalLeaguesResultDTO = { totalLeagues: leagues.length }; + presenter.reset(); + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase.ts b/core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase.ts index 255f734ef..f26ed0579 100644 --- a/core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase.ts +++ b/core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase.ts @@ -1,6 +1,7 @@ -import type { IRaceRegistrationRepository } from '@gridpilot/racing/domain/repositories/IRaceRegistrationRepository'; +import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; import type { IsDriverRegisteredForRaceQueryParamsDTO } from '../dto/RaceRegistrationQueryDTO'; import type { IDriverRegistrationStatusPresenter } from '../presenters/IDriverRegistrationStatusPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; /** * Use Case: IsDriverRegisteredForRaceUseCase @@ -8,15 +9,15 @@ import type { IDriverRegistrationStatusPresenter } from '../presenters/IDriverRe * Checks if a driver is registered for a specific race. * Orchestrates domain logic and delegates presentation to the presenter. */ -export class IsDriverRegisteredForRaceUseCase { - constructor( - private readonly registrationRepository: IRaceRegistrationRepository, - public readonly presenter: IDriverRegistrationStatusPresenter, - ) {} +export class IsDriverRegisteredForRaceUseCase + implements UseCase +{ + constructor(private readonly registrationRepository: IRaceRegistrationRepository) {} - async execute(params: IsDriverRegisteredForRaceQueryParamsDTO): Promise { + async execute(params: IsDriverRegisteredForRaceQueryParamsDTO, presenter: IDriverRegistrationStatusPresenter): Promise { + presenter.reset(); const { raceId, driverId } = params; const isRegistered = await this.registrationRepository.isRegistered(raceId, driverId); - this.presenter.present(isRegistered, raceId, driverId); + presenter.present(isRegistered, raceId, driverId); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/RejectLeagueJoinRequestUseCase.ts b/core/racing/application/use-cases/RejectLeagueJoinRequestUseCase.ts new file mode 100644 index 000000000..30fc04347 --- /dev/null +++ b/core/racing/application/use-cases/RejectLeagueJoinRequestUseCase.ts @@ -0,0 +1,23 @@ +import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import type { IRejectLeagueJoinRequestPresenter, RejectLeagueJoinRequestResultDTO, RejectLeagueJoinRequestViewModel } from '../presenters/IRejectLeagueJoinRequestPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export interface RejectLeagueJoinRequestUseCaseParams { + requestId: string; +} + +export interface RejectLeagueJoinRequestResultDTO { + success: boolean; + message: string; +} + +export class RejectLeagueJoinRequestUseCase implements UseCase { + constructor(private readonly leagueMembershipRepository: ILeagueMembershipRepository) {} + + async execute(params: RejectLeagueJoinRequestUseCaseParams, presenter: IRejectLeagueJoinRequestPresenter): Promise { + await this.leagueMembershipRepository.removeJoinRequest(params.requestId); + const dto: RejectLeagueJoinRequestResultDTO = { success: true, message: 'Join request rejected.' }; + presenter.reset(); + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/racing/application/use-cases/RemoveLeagueMemberUseCase.ts b/core/racing/application/use-cases/RemoveLeagueMemberUseCase.ts new file mode 100644 index 000000000..bd91e2c2c --- /dev/null +++ b/core/racing/application/use-cases/RemoveLeagueMemberUseCase.ts @@ -0,0 +1,31 @@ +import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import type { IRemoveLeagueMemberPresenter, RemoveLeagueMemberResultDTO, RemoveLeagueMemberViewModel } from '../presenters/IRemoveLeagueMemberPresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export interface RemoveLeagueMemberUseCaseParams { + leagueId: string; + targetDriverId: string; +} + +export interface RemoveLeagueMemberResultDTO { + success: boolean; +} + +export class RemoveLeagueMemberUseCase implements UseCase { + constructor(private readonly leagueMembershipRepository: ILeagueMembershipRepository) {} + + async execute(params: RemoveLeagueMemberUseCaseParams, presenter: IRemoveLeagueMemberPresenter): Promise { + const memberships = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId); + const membership = memberships.find(m => m.driverId === params.targetDriverId); + if (!membership) { + throw new Error('Membership not found'); + } + await this.leagueMembershipRepository.saveMembership({ + ...membership, + status: 'inactive', + }); + const dto: RemoveLeagueMemberResultDTO = { success: true }; + presenter.reset(); + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase.ts b/core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase.ts new file mode 100644 index 000000000..5ea5e55ad --- /dev/null +++ b/core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase.ts @@ -0,0 +1,32 @@ +import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import type { IUpdateLeagueMemberRolePresenter, UpdateLeagueMemberRoleResultDTO, UpdateLeagueMemberRoleViewModel } from '../presenters/IUpdateLeagueMemberRolePresenter'; +import type { UseCase } from '@gridpilot/shared/application/UseCase'; + +export interface UpdateLeagueMemberRoleUseCaseParams { + leagueId: string; + targetDriverId: string; + newRole: string; +} + +export interface UpdateLeagueMemberRoleResultDTO { + success: boolean; +} + +export class UpdateLeagueMemberRoleUseCase implements UseCase { + constructor(private readonly leagueMembershipRepository: ILeagueMembershipRepository) {} + + async execute(params: UpdateLeagueMemberRoleUseCaseParams, presenter: IUpdateLeagueMemberRolePresenter): Promise { + const memberships = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId); + const membership = memberships.find(m => m.driverId === params.targetDriverId); + if (!membership) { + throw new Error('Membership not found'); + } + await this.leagueMembershipRepository.saveMembership({ + ...membership, + role: params.newRole, + }); + const dto: UpdateLeagueMemberRoleResultDTO = { success: true }; + presenter.reset(); + presenter.present(dto); + } +} \ No newline at end of file diff --git a/core/shared/package.json b/core/shared/package.json new file mode 100644 index 000000000..f04cc487e --- /dev/null +++ b/core/shared/package.json @@ -0,0 +1,23 @@ +{ + "name": "@gridpilot/shared", + "version": "0.1.0", + "main": "./index.ts", + "types": "./index.ts", + "type": "module", + "exports": { + ".": "./index.ts", + "./logging": "./logging/index.ts", + "./logging/*": "./logging/*", + "./application": "./application/index.ts", + "./application/*": "./application/*", + "./domain": "./domain/index.ts", + "./domain/*": "./domain/*", + "./errors": "./errors/index.ts", + "./errors/*": "./errors/*", + "./presentation": "./presentation/index.ts", + "./presentation/*": "./presentation/*", + "./result": "./result/index.ts", + "./result/*": "./result/*" + }, + "dependencies": {} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8b069fa2a..b5cbc0e92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,10 +34,13 @@ "@types/express": "^4.17.21", "@types/jsdom": "^27.0.0", "@types/node": "^24.10.1", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", "@vitest/ui": "^4.0.15", "cheerio": "^1.0.0", "commander": "^11.0.0", "electron": "^39.2.7", + "eslint": "^8.0.0", "husky": "^9.1.7", "jsdom": "^22.1.0", "prettier": "^3.0.0", @@ -439,7 +442,7 @@ }, "core/shared": { "name": "@gridpilot/shared", - "version": "1.0.0" + "version": "0.1.0" }, "core/social": { "name": "@gridpilot/social", @@ -3294,401 +3297,6 @@ } } }, - "node_modules/@nestjs/platform-express": { - "version": "11.1.9", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.9.tgz", - "integrity": "sha512-GVd3+0lO0mJq2m1kl9hDDnVrX3Nd4oH3oDfklz0pZEVEVS0KVSp63ufHq2Lu9cyPdSBuelJr9iPm2QQ1yX+Kmw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "cors": "2.8.5", - "express": "5.1.0", - "multer": "2.0.2", - "path-to-regexp": "8.3.0", - "tslib": "2.8.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nest" - }, - "peerDependencies": { - "@nestjs/common": "^11.0.0", - "@nestjs/core": "^11.0.0" - } - }, - "node_modules/@nestjs/platform-express/node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@nestjs/platform-express/node_modules/body-parser": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", - "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@nestjs/platform-express/node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@nestjs/platform-express/node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/@nestjs/platform-express/node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.0", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@nestjs/platform-express/node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@nestjs/platform-express/node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@nestjs/platform-express/node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@nestjs/platform-express/node_modules/iconv-lite": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", - "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@nestjs/platform-express/node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@nestjs/platform-express/node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@nestjs/platform-express/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@nestjs/platform-express/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@nestjs/platform-express/node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@nestjs/platform-express/node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@nestjs/platform-express/node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/@nestjs/platform-express/node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/@nestjs/platform-express/node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@nestjs/platform-express/node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@nestjs/platform-express/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@nestjs/platform-express/node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/@nestjs/swagger": { "version": "11.2.3", "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.2.3.tgz", @@ -4897,6 +4505,13 @@ "parse5": "^7.0.0" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -4999,6 +4614,13 @@ "@types/node": "*" } }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -5098,159 +4720,124 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz", - "integrity": "sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", + "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.49.0", - "@typescript-eslint/type-utils": "8.49.0", - "@typescript-eslint/utils": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/type-utils": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.4", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.49.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" + "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@typescript-eslint/parser": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.49.0.tgz", - "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.49.0", - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/typescript-estree": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", "debug": "^4.3.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.49.0.tgz", - "integrity": "sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.49.0", - "@typescript-eslint/types": "^8.49.0", - "debug": "^4.3.4" + "eslint": "^7.0.0 || ^8.0.0" }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.49.0.tgz", - "integrity": "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0" + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.49.0.tgz", - "integrity": "sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.49.0.tgz", - "integrity": "sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", + "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/typescript-estree": "8.49.0", - "@typescript-eslint/utils": "8.49.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/utils": "6.21.0", "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@typescript-eslint/types": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.49.0.tgz", - "integrity": "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", @@ -5258,31 +4845,32 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz", - "integrity": "sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/project-service": "8.49.0", - "@typescript-eslint/tsconfig-utils": "8.49.0", - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", "debug": "^4.3.4", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.1.0" + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { @@ -5296,9 +4884,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", "dev": true, "license": "ISC", "dependencies": { @@ -5312,60 +4900,49 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.49.0.tgz", - "integrity": "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.49.0", - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/typescript-estree": "8.49.0" + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "semver": "^7.5.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^7.0.0 || ^8.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz", - "integrity": "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.49.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -6059,6 +5636,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/array.prototype.findlast": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", @@ -7695,6 +7282,19 @@ "node": ">=0.3.1" } }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -9804,6 +9404,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -10691,14 +10312,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -13480,6 +13093,16 @@ "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", "license": "MIT" }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -14943,36 +14566,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/router/node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/rrweb-cssom": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", @@ -16510,16 +16103,16 @@ } }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18.12" + "node": ">=16" }, "peerDependencies": { - "typescript": ">=4.8.4" + "typescript": ">=4.2.0" } }, "node_modules/ts-interface-checker": { diff --git a/package.json b/package.json index b71e1d3e9..4c5bb727f 100644 --- a/package.json +++ b/package.json @@ -71,10 +71,13 @@ "@types/express": "^4.17.21", "@types/jsdom": "^27.0.0", "@types/node": "^24.10.1", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", "@vitest/ui": "^4.0.15", "cheerio": "^1.0.0", "commander": "^11.0.0", "electron": "^39.2.7", + "eslint": "^8.0.0", "husky": "^9.1.7", "jsdom": "^22.1.0", "prettier": "^3.0.0", diff --git a/tests/application/CompleteDriverOnboardingUseCase.spec.ts b/tests/application/CompleteDriverOnboardingUseCase.spec.ts new file mode 100644 index 000000000..24f50931b --- /dev/null +++ b/tests/application/CompleteDriverOnboardingUseCase.spec.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { CompleteDriverOnboardingUseCase } from '../../core/racing/application/use-cases/CompleteDriverOnboardingUseCase'; +import { IDriverRepository } from '../../core/racing/domain/repositories/IDriverRepository'; +import { CompleteOnboardingPresenter } from '../../apps/api/src/modules/driver/presenters/CompleteOnboardingPresenter'; + +describe('CompleteDriverOnboardingUseCase', () => { + let useCase: CompleteDriverOnboardingUseCase; + let driverRepository: { findById: any; save: any }; + + beforeEach(() => { + driverRepository = { + findById: vi.fn(), + save: vi.fn(), + }; + useCase = new CompleteDriverOnboardingUseCase(driverRepository as IDriverRepository); + }); + + describe('execute', () => { + it('should create a new driver and return success', async () => { + const input = { + userId: 'user-123', + firstName: 'John', + lastName: 'Doe', + displayName: 'John Doe', + country: 'US', + timezone: 'America/New_York', + bio: 'Racing enthusiast', + }; + + driverRepository.findById.mockResolvedValue(null); // Driver doesn't exist + driverRepository.save.mockResolvedValue(undefined); + + const presenter = new CompleteOnboardingPresenter(); + + await useCase.execute(input, presenter); + + expect(driverRepository.findById).toHaveBeenCalledWith('user-123'); + expect(driverRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'user-123', + iracingId: 'user-123', + name: 'John Doe', + country: 'US', + bio: 'Racing enthusiast', + }) + ); + expect(presenter.viewModel).toEqual({ + success: true, + driverId: 'user-123', + errorMessage: undefined, + }); + }); + + it('should return error if driver already exists', async () => { + const input = { + userId: 'user-123', + firstName: 'John', + lastName: 'Doe', + displayName: 'John Doe', + country: 'US', + }; + + const existingDriver = { + id: 'user-123', + name: 'Existing Driver', + }; + + driverRepository.findById.mockResolvedValue(existingDriver); + + const presenter = new CompleteOnboardingPresenter(); + + await useCase.execute(input, presenter); + + expect(driverRepository.findById).toHaveBeenCalledWith('user-123'); + expect(driverRepository.save).not.toHaveBeenCalled(); + expect(presenter.viewModel).toEqual({ + success: false, + driverId: undefined, + errorMessage: 'Driver already exists', + }); + }); + + it('should handle domain validation errors', async () => { + const input = { + userId: 'user-123', + firstName: 'John', + lastName: 'Doe', + displayName: 'John Doe', + country: 'INVALID', // Invalid country code + }; + + driverRepository.findById.mockResolvedValue(null); + + const presenter = new CompleteOnboardingPresenter(); + + await useCase.execute(input, presenter); + + expect(presenter.viewModel.success).toBe(false); + expect(presenter.viewModel.errorMessage).toContain('Country must be a valid ISO code'); + }); + }); +}); \ No newline at end of file diff --git a/tests/application/GetTotalDriversUseCase.spec.ts b/tests/application/GetTotalDriversUseCase.spec.ts new file mode 100644 index 000000000..333b5f6a8 --- /dev/null +++ b/tests/application/GetTotalDriversUseCase.spec.ts @@ -0,0 +1,33 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { GetTotalDriversUseCase } from '../../core/racing/application/use-cases/GetTotalDriversUseCase'; +import { IDriverRepository } from '../../core/racing/domain/repositories/IDriverRepository'; +import { DriverStatsPresenter } from '../../apps/api/src/modules/driver/presenters/DriverStatsPresenter'; + +describe('GetTotalDriversUseCase', () => { + let useCase: GetTotalDriversUseCase; + let driverRepository: { findAll: any }; + + beforeEach(() => { + driverRepository = { + findAll: vi.fn(), + }; + useCase = new GetTotalDriversUseCase(driverRepository as IDriverRepository); + }); + + it('should return total drivers count', async () => { + // Arrange + const mockDrivers = [ + { id: '1', name: 'Driver 1' }, + { id: '2', name: 'Driver 2' }, + ]; + driverRepository.findAll.mockResolvedValue(mockDrivers); + const presenter = new DriverStatsPresenter(); + + // Act + await useCase.execute(undefined, presenter); + + // Assert + expect(driverRepository.findAll).toHaveBeenCalled(); + expect(presenter.viewModel).toEqual({ totalDrivers: 2 }); + }); +}); \ No newline at end of file diff --git a/tests/racing-application/ApproveLeagueJoinRequestUseCase.spec.ts b/tests/racing-application/ApproveLeagueJoinRequestUseCase.spec.ts new file mode 100644 index 000000000..7de5c16b2 --- /dev/null +++ b/tests/racing-application/ApproveLeagueJoinRequestUseCase.spec.ts @@ -0,0 +1,45 @@ +import { ApproveLeagueJoinRequestUseCase } from '../../core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase'; +import { ApproveLeagueJoinRequestPresenter } from '../../apps/api/src/modules/league/presenters/ApproveLeagueJoinRequestPresenter'; +import { ILeagueMembershipRepository } from '../../core/racing/domain/repositories/ILeagueMembershipRepository'; + +describe('ApproveLeagueJoinRequestUseCase', () => { + let useCase: ApproveLeagueJoinRequestUseCase; + let leagueMembershipRepository: jest.Mocked; + let presenter: ApproveLeagueJoinRequestPresenter; + + beforeEach(() => { + leagueMembershipRepository = { + getJoinRequests: jest.fn(), + removeJoinRequest: jest.fn(), + saveMembership: jest.fn(), + } as any; + presenter = new ApproveLeagueJoinRequestPresenter(); + useCase = new ApproveLeagueJoinRequestUseCase(leagueMembershipRepository); + }); + + it('should approve join request and save membership', async () => { + const leagueId = 'league-1'; + const requestId = 'req-1'; + const joinRequests = [{ id: requestId, leagueId, driverId: 'driver-1', requestedAt: new Date(), message: 'msg' }]; + + leagueMembershipRepository.getJoinRequests.mockResolvedValue(joinRequests); + + await useCase.execute({ leagueId, requestId }, presenter); + + expect(leagueMembershipRepository.removeJoinRequest).toHaveBeenCalledWith(requestId); + expect(leagueMembershipRepository.saveMembership).toHaveBeenCalledWith({ + leagueId, + driverId: 'driver-1', + role: 'member', + status: 'active', + joinedAt: expect.any(Date), + }); + expect(presenter.viewModel).toEqual({ success: true, message: 'Join request approved.' }); + }); + + it('should throw error if request not found', async () => { + leagueMembershipRepository.getJoinRequests.mockResolvedValue([]); + + await expect(useCase.execute({ leagueId: 'league-1', requestId: 'req-1' }, presenter)).rejects.toThrow('Join request not found'); + }); +}); \ No newline at end of file diff --git a/tests/racing-application/GetLeagueJoinRequestsUseCase.spec.ts b/tests/racing-application/GetLeagueJoinRequestsUseCase.spec.ts new file mode 100644 index 000000000..ae147a14c --- /dev/null +++ b/tests/racing-application/GetLeagueJoinRequestsUseCase.spec.ts @@ -0,0 +1,46 @@ +import { GetLeagueJoinRequestsUseCase } from '../../core/racing/application/use-cases/GetLeagueJoinRequestsUseCase'; +import { LeagueJoinRequestsPresenter } from '../../apps/api/src/modules/league/presenters/LeagueJoinRequestsPresenter'; +import { ILeagueMembershipRepository } from '../../core/racing/domain/repositories/ILeagueMembershipRepository'; +import { IDriverRepository } from '../../core/racing/domain/repositories/IDriverRepository'; + +describe('GetLeagueJoinRequestsUseCase', () => { + let useCase: GetLeagueJoinRequestsUseCase; + let leagueMembershipRepository: jest.Mocked; + let driverRepository: jest.Mocked; + let presenter: LeagueJoinRequestsPresenter; + + beforeEach(() => { + leagueMembershipRepository = { + getJoinRequests: jest.fn(), + } as any; + driverRepository = { + findByIds: jest.fn(), + } as any; + presenter = new LeagueJoinRequestsPresenter(); + useCase = new GetLeagueJoinRequestsUseCase(leagueMembershipRepository, driverRepository); + }); + + it('should return join requests with drivers', async () => { + const leagueId = 'league-1'; + const joinRequests = [ + { id: 'req-1', leagueId, driverId: 'driver-1', requestedAt: new Date(), message: 'msg' }, + ]; + const drivers = [{ id: 'driver-1', name: 'Driver 1' }]; + + leagueMembershipRepository.getJoinRequests.mockResolvedValue(joinRequests); + driverRepository.findByIds.mockResolvedValue(drivers); + + await useCase.execute({ leagueId }, presenter); + + expect(presenter.viewModel.joinRequests).toEqual([ + { + id: 'req-1', + leagueId, + driverId: 'driver-1', + requestedAt: expect.any(Date), + message: 'msg', + driver: { id: 'driver-1', name: 'Driver 1' }, + }, + ]); + }); +}); \ No newline at end of file diff --git a/tests/racing-application/RejectLeagueJoinRequestUseCase.spec.ts b/tests/racing-application/RejectLeagueJoinRequestUseCase.spec.ts new file mode 100644 index 000000000..1a01d37cb --- /dev/null +++ b/tests/racing-application/RejectLeagueJoinRequestUseCase.spec.ts @@ -0,0 +1,26 @@ +import { RejectLeagueJoinRequestUseCase } from '../../core/racing/application/use-cases/RejectLeagueJoinRequestUseCase'; +import { RejectLeagueJoinRequestPresenter } from '../../apps/api/src/modules/league/presenters/RejectLeagueJoinRequestPresenter'; +import { ILeagueMembershipRepository } from '../../core/racing/domain/repositories/ILeagueMembershipRepository'; + +describe('RejectLeagueJoinRequestUseCase', () => { + let useCase: RejectLeagueJoinRequestUseCase; + let leagueMembershipRepository: jest.Mocked; + let presenter: RejectLeagueJoinRequestPresenter; + + beforeEach(() => { + leagueMembershipRepository = { + removeJoinRequest: jest.fn(), + } as any; + presenter = new RejectLeagueJoinRequestPresenter(); + useCase = new RejectLeagueJoinRequestUseCase(leagueMembershipRepository); + }); + + it('should reject join request', async () => { + const requestId = 'req-1'; + + await useCase.execute({ requestId }, presenter); + + expect(leagueMembershipRepository.removeJoinRequest).toHaveBeenCalledWith(requestId); + expect(presenter.viewModel).toEqual({ success: true, message: 'Join request rejected.' }); + }); +}); \ No newline at end of file diff --git a/tests/racing-application/RemoveLeagueMemberUseCase.spec.ts b/tests/racing-application/RemoveLeagueMemberUseCase.spec.ts new file mode 100644 index 000000000..f5a220ba7 --- /dev/null +++ b/tests/racing-application/RemoveLeagueMemberUseCase.spec.ts @@ -0,0 +1,43 @@ +import { RemoveLeagueMemberUseCase } from '../../core/racing/application/use-cases/RemoveLeagueMemberUseCase'; +import { RemoveLeagueMemberPresenter } from '../../apps/api/src/modules/league/presenters/RemoveLeagueMemberPresenter'; +import { ILeagueMembershipRepository } from '../../core/racing/domain/repositories/ILeagueMembershipRepository'; + +describe('RemoveLeagueMemberUseCase', () => { + let useCase: RemoveLeagueMemberUseCase; + let leagueMembershipRepository: jest.Mocked; + let presenter: RemoveLeagueMemberPresenter; + + beforeEach(() => { + leagueMembershipRepository = { + getLeagueMembers: jest.fn(), + saveMembership: jest.fn(), + } as any; + presenter = new RemoveLeagueMemberPresenter(); + useCase = new RemoveLeagueMemberUseCase(leagueMembershipRepository); + }); + + it('should remove league member by setting status to inactive', async () => { + const leagueId = 'league-1'; + const targetDriverId = 'driver-1'; + const memberships = [{ leagueId, driverId: targetDriverId, role: 'member', status: 'active', joinedAt: new Date() }]; + + leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships); + + await useCase.execute({ leagueId, targetDriverId }, presenter); + + expect(leagueMembershipRepository.saveMembership).toHaveBeenCalledWith({ + leagueId, + driverId: targetDriverId, + role: 'member', + status: 'inactive', + joinedAt: expect.any(Date), + }); + expect(presenter.viewModel).toEqual({ success: true }); + }); + + it('should throw error if membership not found', async () => { + leagueMembershipRepository.getLeagueMembers.mockResolvedValue([]); + + await expect(useCase.execute({ leagueId: 'league-1', targetDriverId: 'driver-1' }, presenter)).rejects.toThrow('Membership not found'); + }); +}); \ No newline at end of file diff --git a/tests/racing-application/UpdateLeagueMemberRoleUseCase.spec.ts b/tests/racing-application/UpdateLeagueMemberRoleUseCase.spec.ts new file mode 100644 index 000000000..90b78f374 --- /dev/null +++ b/tests/racing-application/UpdateLeagueMemberRoleUseCase.spec.ts @@ -0,0 +1,44 @@ +import { UpdateLeagueMemberRoleUseCase } from '../../core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase'; +import { UpdateLeagueMemberRolePresenter } from '../../apps/api/src/modules/league/presenters/UpdateLeagueMemberRolePresenter'; +import { ILeagueMembershipRepository } from '../../core/racing/domain/repositories/ILeagueMembershipRepository'; + +describe('UpdateLeagueMemberRoleUseCase', () => { + let useCase: UpdateLeagueMemberRoleUseCase; + let leagueMembershipRepository: jest.Mocked; + let presenter: UpdateLeagueMemberRolePresenter; + + beforeEach(() => { + leagueMembershipRepository = { + getLeagueMembers: jest.fn(), + saveMembership: jest.fn(), + } as any; + presenter = new UpdateLeagueMemberRolePresenter(); + useCase = new UpdateLeagueMemberRoleUseCase(leagueMembershipRepository); + }); + + it('should update league member role', async () => { + const leagueId = 'league-1'; + const targetDriverId = 'driver-1'; + const newRole = 'admin'; + const memberships = [{ leagueId, driverId: targetDriverId, role: 'member', status: 'active', joinedAt: new Date() }]; + + leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships); + + await useCase.execute({ leagueId, targetDriverId, newRole }, presenter); + + expect(leagueMembershipRepository.saveMembership).toHaveBeenCalledWith({ + leagueId, + driverId: targetDriverId, + role: 'admin', + status: 'active', + joinedAt: expect.any(Date), + }); + expect(presenter.viewModel).toEqual({ success: true }); + }); + + it('should throw error if membership not found', async () => { + leagueMembershipRepository.getLeagueMembers.mockResolvedValue([]); + + await expect(useCase.execute({ leagueId: 'league-1', targetDriverId: 'driver-1', newRole: 'admin' }, presenter)).rejects.toThrow('Membership not found'); + }); +}); \ No newline at end of file