diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 38bfc5e89..c572e9912 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -137,6 +137,17 @@ "seasonName" ] }, + "SponsorshipDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + }, "SponsoredLeagueDTO": { "type": "object", "properties": { @@ -167,6 +178,37 @@ "sponsorName" ] }, + "SponsorProfileDTO": { + "type": "object", + "properties": { + "companyName": { + "type": "string" + }, + "contactName": { + "type": "string" + }, + "contactEmail": { + "type": "string" + }, + "contactPhone": { + "type": "string" + }, + "website": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "companyName", + "contactName", + "contactEmail", + "contactPhone", + "website", + "description" + ] + }, "SponsorDashboardMetricsDTO": { "type": "object", "properties": { @@ -255,6 +297,21 @@ "name" ] }, + "RenewalAlertDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, "RejectSponsorshipRequestInputDTO": { "type": "object", "properties": { @@ -266,6 +323,144 @@ "respondedBy" ] }, + "RaceDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "date": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "date" + ] + }, + "PrivacySettingsDTO": { + "type": "object", + "properties": { + "publicProfile": { + "type": "boolean" + }, + "showStats": { + "type": "boolean" + }, + "showActiveSponsorships": { + "type": "boolean" + }, + "allowDirectContact": { + "type": "boolean" + } + }, + "required": [ + "publicProfile", + "showStats", + "showActiveSponsorships", + "allowDirectContact" + ] + }, + "PaymentMethodDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + }, + "NotificationSettingsDTO": { + "type": "object", + "properties": { + "emailNewSponsorships": { + "type": "boolean" + }, + "emailWeeklyReport": { + "type": "boolean" + }, + "emailRaceAlerts": { + "type": "boolean" + }, + "emailPaymentAlerts": { + "type": "boolean" + }, + "emailNewOpportunities": { + "type": "boolean" + }, + "emailContractExpiry": { + "type": "boolean" + } + }, + "required": [ + "emailNewSponsorships", + "emailWeeklyReport", + "emailRaceAlerts", + "emailPaymentAlerts", + "emailNewOpportunities", + "emailContractExpiry" + ] + }, + "LeagueDetailDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "game": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "game" + ] + }, + "InvoiceDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "invoiceNumber": { + "type": "string" + }, + "date": { + "type": "string" + }, + "dueDate": { + "type": "string" + }, + "amount": { + "type": "number" + }, + "vatAmount": { + "type": "number" + }, + "totalAmount": { + "type": "number" + } + }, + "required": [ + "id", + "invoiceNumber", + "date", + "dueDate", + "amount", + "vatAmount", + "totalAmount" + ] + }, "GetSponsorSponsorshipsQueryParamsDTO": { "type": "object", "properties": { @@ -303,6 +498,44 @@ "entityId" ] }, + "GetEntitySponsorshipPricingResultDTO": { + "type": "object", + "properties": { + "entityType": { + "type": "string" + }, + "entityId": { + "type": "string" + } + }, + "required": [ + "entityType", + "entityId" + ] + }, + "DriverDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "iracingId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "country": { + "type": "string" + } + }, + "required": [ + "id", + "iracingId", + "name", + "country" + ] + }, "CreateSponsorInputDTO": { "type": "object", "properties": { @@ -318,6 +551,75 @@ "contactEmail" ] }, + "BillingStatsDTO": { + "type": "object", + "properties": { + "totalSpent": { + "type": "number" + }, + "pendingAmount": { + "type": "number" + }, + "nextPaymentDate": { + "type": "string" + }, + "nextPaymentAmount": { + "type": "number" + }, + "activeSponsorships": { + "type": "number" + }, + "averageMonthlySpend": { + "type": "number" + } + }, + "required": [ + "totalSpent", + "pendingAmount", + "nextPaymentDate", + "nextPaymentAmount", + "activeSponsorships", + "averageMonthlySpend" + ] + }, + "AvailableLeagueDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "game": { + "type": "string" + }, + "drivers": { + "type": "number" + }, + "avgViewsPerRace": { + "type": "number" + } + }, + "required": [ + "id", + "name", + "game", + "drivers", + "avgViewsPerRace" + ] + }, + "ActivityItemDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + }, "AcceptSponsorshipRequestInputDTO": { "type": "object", "properties": { @@ -723,25 +1025,6 @@ "avatarUrl" ] }, - "RaceDTO": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "date": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "date" - ] - }, "RaceActionParamsDTO": { "type": "object", "properties": { @@ -772,6 +1055,29 @@ "adminId" ] }, + "ImportRaceResultsSummaryDTO": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "raceId": { + "type": "string" + }, + "driversProcessed": { + "type": "number" + }, + "resultsRecorded": { + "type": "number" + } + }, + "required": [ + "success", + "raceId", + "driversProcessed", + "resultsRecorded" + ] + }, "ImportRaceResultsDTO": { "type": "object", "properties": { @@ -787,7 +1093,7 @@ "resultsFileContent" ] }, - "GetRaceDetailParamsDTODTO": { + "GetRaceDetailParamsDTO": { "type": "object", "properties": { "raceId": { @@ -891,6 +1197,12 @@ }, "scheduledAt": { "type": "string" + }, + "status": { + "type": "string" + }, + "isMyLeague": { + "type": "boolean" } }, "required": [ @@ -899,7 +1211,9 @@ "leagueName", "track", "car", - "scheduledAt" + "scheduledAt", + "status", + "isMyLeague" ] }, "DashboardLeagueStandingSummaryDTO": { @@ -1024,6 +1338,41 @@ "enum" ] }, + "AllRacesListItemDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "track": { + "type": "string" + }, + "car": { + "type": "string" + }, + "scheduledAt": { + "type": "string" + }, + "status": { + "type": "string" + }, + "leagueId": { + "type": "string" + }, + "leagueName": { + "type": "string" + } + }, + "required": [ + "id", + "track", + "car", + "scheduledAt", + "status", + "leagueId", + "leagueName" + ] + }, "UpdatePaymentStatusInputDTO": { "type": "object", "properties": { @@ -1297,6 +1646,40 @@ "success" ] }, + "WithdrawFromLeagueWalletOutputDTO": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "WithdrawFromLeagueWalletInputDTO": { + "type": "object", + "properties": { + "amount": { + "type": "number" + }, + "currency": { + "type": "string" + }, + "seasonId": { + "type": "string" + }, + "destinationAccount": { + "type": "string" + } + }, + "required": [ + "amount", + "currency", + "seasonId", + "destinationAccount" + ] + }, "UpdateLeagueMemberRoleOutputDTO": { "type": "object", "properties": { @@ -1327,6 +1710,17 @@ "targetDriverId" ] }, + "TotalLeaguesDTO": { + "type": "object", + "properties": { + "totalLeagues": { + "type": "number" + } + }, + "required": [ + "totalLeagues" + ] + }, "SeasonDTO": { "type": "object", "properties": { @@ -1408,6 +1802,9 @@ "id": { "type": "string" }, + "leagueId": { + "type": "string" + }, "raceId": { "type": "string" }, @@ -1427,6 +1824,7 @@ }, "required": [ "id", + "leagueId", "raceId", "protestingDriverId", "accusedDriverId", @@ -1442,11 +1840,33 @@ }, "name": { "type": "string" + }, + "description": { + "type": "string" + }, + "ownerId": { + "type": "string" + }, + "settings": { + "type": "object" + }, + "maxDrivers": { + "type": "number" + }, + "sessionDuration": { + "type": "number" + }, + "visibility": { + "type": "string" } }, "required": [ "id", - "name" + "name", + "description", + "ownerId", + "settings", + "maxDrivers" ] }, "LeagueSummaryDTO": { @@ -1513,6 +1933,44 @@ "status" ] }, + "LeagueScoringPresetDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "description" + ] + }, + "LeagueMembershipDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "leagueId": { + "type": "string" + }, + "driverId": { + "type": "string" + } + }, + "required": [ + "id", + "leagueId", + "driverId" + ] + }, "LeagueMemberDTO": { "type": "object", "properties": { @@ -1634,6 +2092,52 @@ "canUpdateRoles" ] }, + "WalletTransactionDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + }, + "GetLeagueWalletOutputDTO": { + "type": "object", + "properties": { + "balance": { + "type": "number" + }, + "currency": { + "type": "string" + }, + "totalRevenue": { + "type": "number" + }, + "totalFees": { + "type": "number" + }, + "totalWithdrawals": { + "type": "number" + }, + "pendingPayouts": { + "type": "number" + }, + "canWithdraw": { + "type": "boolean" + } + }, + "required": [ + "balance", + "currency", + "totalRevenue", + "totalFees", + "totalWithdrawals", + "pendingPayouts", + "canWithdraw" + ] + }, "GetLeagueSeasonsQueryDTO": { "type": "object", "properties": { @@ -2031,29 +2535,6 @@ "rank" ] }, - "DriverDTO": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "iracingId": { - "type": "string" - }, - "name": { - "type": "string" - }, - "country": { - "type": "string" - } - }, - "required": [ - "id", - "iracingId", - "name", - "country" - ] - }, "CompleteOnboardingOutputDTO": { "type": "object", "properties": { diff --git a/apps/api/src/domain/driver/DriverProviders.ts b/apps/api/src/domain/driver/DriverProviders.ts index 9992ffdc0..9e49d3c0a 100644 --- a/apps/api/src/domain/driver/DriverProviders.ts +++ b/apps/api/src/domain/driver/DriverProviders.ts @@ -10,7 +10,10 @@ import { DriverExtendedProfileProvider } from '@core/racing/application/ports/Dr import { IImageServicePort } from '@core/racing/application/ports/IImageServicePort'; import { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository'; import { INotificationPreferenceRepository } from '@core/notifications/domain/repositories/INotificationPreferenceRepository'; -import type { Logger } from "@core/shared/application"; +import type { Logger } from '@core/shared/application'; +import type { ITeamRepository } from '@core/racing/domain/repositories/ITeamRepository'; +import type { ITeamMembershipRepository } from '@core/racing/domain/repositories/ITeamMembershipRepository'; +import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository'; // Import use cases import { GetDriversLeaderboardUseCase } from '@core/racing/application/use-cases/GetDriversLeaderboardUseCase'; @@ -29,6 +32,9 @@ import { InMemoryDriverExtendedProfileProvider } from '@adapters/racing/ports/In import { InMemoryImageServiceAdapter } from '@adapters/media/ports/InMemoryImageServiceAdapter'; import { InMemoryRaceRegistrationRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRegistrationRepository'; import { InMemoryNotificationPreferenceRepository } from '@adapters/notifications/persistence/inmemory/InMemoryNotificationPreferenceRepository'; +import { InMemoryTeamRepository } from '@adapters/racing/persistence/inmemory/InMemoryTeamRepository'; +import { InMemoryTeamMembershipRepository } from '@adapters/racing/persistence/inmemory/InMemoryTeamMembershipRepository'; +import { InMemorySocialGraphRepository } from '@core/social/infrastructure/inmemory/InMemorySocialAndFeed'; import { ConsoleLogger } from '@adapters/logging/ConsoleLogger'; // Define injection tokens @@ -40,6 +46,9 @@ export const DRIVER_EXTENDED_PROFILE_PROVIDER_TOKEN = 'DriverExtendedProfileProv export const IMAGE_SERVICE_PORT_TOKEN = 'IImageServicePort'; export const RACE_REGISTRATION_REPOSITORY_TOKEN = 'IRaceRegistrationRepository'; export const NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN = 'INotificationPreferenceRepository'; +export const TEAM_REPOSITORY_TOKEN = 'ITeamRepository'; +export const TEAM_MEMBERSHIP_REPOSITORY_TOKEN = 'ITeamMembershipRepository'; +export const SOCIAL_GRAPH_REPOSITORY_TOKEN = 'ISocialGraphRepository'; export const LOGGER_TOKEN = 'Logger'; // Already defined in AuthProviders, but good to have here too // Use case tokens @@ -92,6 +101,22 @@ export const DriverProviders: Provider[] = [ useFactory: (logger: Logger) => new InMemoryNotificationPreferenceRepository(logger), inject: [LOGGER_TOKEN], }, + { + provide: TEAM_REPOSITORY_TOKEN, + useFactory: (logger: Logger) => new InMemoryTeamRepository(logger), + inject: [LOGGER_TOKEN], + }, + { + provide: TEAM_MEMBERSHIP_REPOSITORY_TOKEN, + useFactory: (logger: Logger) => new InMemoryTeamMembershipRepository(logger), + inject: [LOGGER_TOKEN], + }, + { + provide: SOCIAL_GRAPH_REPOSITORY_TOKEN, + useFactory: (logger: Logger) => + new InMemorySocialGraphRepository(logger, { drivers: [], friendships: [], feedEvents: [] }), + inject: [LOGGER_TOKEN], + }, { provide: LOGGER_TOKEN, useClass: ConsoleLogger, @@ -99,8 +124,13 @@ export const DriverProviders: Provider[] = [ // Use cases { provide: GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN, - useFactory: (driverRepo: IDriverRepository, rankingService: IRankingService, driverStatsService: IDriverStatsService, imageService: IImageServicePort, logger: Logger) => - new GetDriversLeaderboardUseCase(driverRepo, rankingService, driverStatsService, imageService, logger), + useFactory: ( + driverRepo: IDriverRepository, + rankingService: IRankingService, + driverStatsService: IDriverStatsService, + imageService: IImageServicePort, + logger: Logger, + ) => new GetDriversLeaderboardUseCase(driverRepo, rankingService, driverStatsService, imageService, logger), inject: [DRIVER_REPOSITORY_TOKEN, RANKING_SERVICE_TOKEN, DRIVER_STATS_SERVICE_TOKEN, IMAGE_SERVICE_PORT_TOKEN, LOGGER_TOKEN], }, { @@ -115,7 +145,8 @@ export const DriverProviders: Provider[] = [ }, { provide: IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN, - useFactory: (registrationRepo: IRaceRegistrationRepository, logger: Logger) => new IsDriverRegisteredForRaceUseCase(registrationRepo, logger), + useFactory: (registrationRepo: IRaceRegistrationRepository, logger: Logger) => + new IsDriverRegisteredForRaceUseCase(registrationRepo, logger), inject: [RACE_REGISTRATION_REPOSITORY_TOKEN, LOGGER_TOKEN], }, { @@ -125,18 +156,59 @@ export const DriverProviders: Provider[] = [ }, { provide: GET_PROFILE_OVERVIEW_USE_CASE_TOKEN, - useFactory: (driverRepo: IDriverRepository, imageService: IImageServicePort, driverExtendedProfileProvider: DriverExtendedProfileProvider, logger: Logger) => + useFactory: ( + driverRepo: IDriverRepository, + teamRepository: ITeamRepository, + teamMembershipRepository: ITeamMembershipRepository, + socialRepository: ISocialGraphRepository, + imageService: IImageServicePort, + driverExtendedProfileProvider: DriverExtendedProfileProvider, + driverStatsService: IDriverStatsService, + rankingService: IRankingService, + ) => new GetProfileOverviewUseCase( driverRepo, - // TODO: Add teamRepository, teamMembershipRepository, socialRepository, etc. - null as any, // teamRepository - null as any, // teamMembershipRepository - null as any, // socialRepository + teamRepository, + teamMembershipRepository, + socialRepository, imageService, driverExtendedProfileProvider, - () => null, // getDriverStats - () => [], // getAllDriverRankings + (driverId: string) => { + const stats = driverStatsService.getDriverStats(driverId); + if (!stats) { + return null; + } + + return { + rating: stats.rating, + wins: stats.wins, + podiums: stats.podiums, + dnfs: 0, + totalRaces: stats.totalRaces, + avgFinish: null, + bestFinish: null, + worstFinish: null, + overallRank: stats.overallRank, + consistency: null, + percentile: null, + }; + }, + () => + rankingService.getAllDriverRankings().map(ranking => ({ + driverId: ranking.driverId, + rating: ranking.rating, + overallRank: ranking.overallRank, + })), ), - inject: [DRIVER_REPOSITORY_TOKEN, IMAGE_SERVICE_PORT_TOKEN, DRIVER_EXTENDED_PROFILE_PROVIDER_TOKEN, LOGGER_TOKEN], + inject: [ + DRIVER_REPOSITORY_TOKEN, + TEAM_REPOSITORY_TOKEN, + TEAM_MEMBERSHIP_REPOSITORY_TOKEN, + SOCIAL_GRAPH_REPOSITORY_TOKEN, + IMAGE_SERVICE_PORT_TOKEN, + DRIVER_EXTENDED_PROFILE_PROVIDER_TOKEN, + DRIVER_STATS_SERVICE_TOKEN, + RANKING_SERVICE_TOKEN, + ], }, ]; diff --git a/apps/api/src/domain/driver/presenters/DriversLeaderboardPresenter.test.ts b/apps/api/src/domain/driver/presenters/DriversLeaderboardPresenter.test.ts index 6f89f07aa..35d157bc7 100644 --- a/apps/api/src/domain/driver/presenters/DriversLeaderboardPresenter.test.ts +++ b/apps/api/src/domain/driver/presenters/DriversLeaderboardPresenter.test.ts @@ -51,7 +51,7 @@ describe('DriversLeaderboardPresenter', () => { id: 'driver-1', name: 'Driver One', rating: 2500, - skillLevel: 'Pro', + skillLevel: 'advanced', nationality: 'US', racesCompleted: 50, wins: 10, @@ -64,7 +64,7 @@ describe('DriversLeaderboardPresenter', () => { id: 'driver-2', name: 'Driver Two', rating: 2400, - skillLevel: 'Pro', + skillLevel: 'intermediate', nationality: 'DE', racesCompleted: 40, wins: 5, @@ -137,6 +137,45 @@ describe('DriversLeaderboardPresenter', () => { expect(result.drivers[0].racesCompleted).toBe(0); expect(result.drivers[0].wins).toBe(0); expect(result.drivers[0].podiums).toBe(0); + expect(result.drivers[0].isActive).toBe(false); + }); + + it('should derive skill level from rating bands', () => { + const dto: DriversLeaderboardResultDTO = { + drivers: [ + { id: 'd1', name: 'Beginner', country: 'US', iracingId: '1', joinedAt: new Date() }, + { id: 'd2', name: 'Intermediate', country: 'US', iracingId: '2', joinedAt: new Date() }, + { id: 'd3', name: 'Advanced', country: 'US', iracingId: '3', joinedAt: new Date() }, + { id: 'd4', name: 'Pro', country: 'US', iracingId: '4', joinedAt: new Date() }, + ], + rankings: [ + { driverId: 'd1', rating: 1700, overallRank: 4 }, + { driverId: 'd2', rating: 2000, overallRank: 3 }, + { driverId: 'd3', rating: 2600, overallRank: 2 }, + { driverId: 'd4', rating: 3100, overallRank: 1 }, + ], + stats: { + d1: { racesCompleted: 5, wins: 0, podiums: 0 }, + d2: { racesCompleted: 5, wins: 0, podiums: 0 }, + d3: { racesCompleted: 5, wins: 0, podiums: 0 }, + d4: { racesCompleted: 5, wins: 0, podiums: 0 }, + }, + avatarUrls: { + d1: 'avatar-1', + d2: 'avatar-2', + d3: 'avatar-3', + d4: 'avatar-4', + }, + }; + + presenter.present(dto); + const result = presenter.viewModel; + + const levels = result.drivers + .sort((a, b) => a.rating - b.rating) + .map(d => d.skillLevel); + + expect(levels).toEqual(['beginner', 'intermediate', 'advanced', 'pro']); }); }); diff --git a/apps/api/src/domain/driver/presenters/DriversLeaderboardPresenter.ts b/apps/api/src/domain/driver/presenters/DriversLeaderboardPresenter.ts index 85d6c04a8..7720a24dc 100644 --- a/apps/api/src/domain/driver/presenters/DriversLeaderboardPresenter.ts +++ b/apps/api/src/domain/driver/presenters/DriversLeaderboardPresenter.ts @@ -1,6 +1,7 @@ -import { DriversLeaderboardDTO, DriverLeaderboardItemDTO } from '../dtos/DriversLeaderboardDTO'; +import { DriversLeaderboardDTO } from '../dtos/DriversLeaderboardDTO'; import { DriverLeaderboardItemDTO } from '../dtos/DriverLeaderboardItemDTO'; import type { IDriversLeaderboardPresenter, DriversLeaderboardResultDTO } from '../../../../../core/racing/application/presenters/IDriversLeaderboardPresenter'; +import { SkillLevelService } from '../../../../../core/racing/domain/services/SkillLevelService'; export class DriversLeaderboardPresenter implements IDriversLeaderboardPresenter { private result: DriversLeaderboardDTO | null = null; @@ -15,16 +16,21 @@ export class DriversLeaderboardPresenter implements IDriversLeaderboardPresenter const stats = dto.stats[driver.id]; const avatarUrl = dto.avatarUrls[driver.id]; + const rating = ranking?.rating ?? 0; + const racesCompleted = stats?.racesCompleted ?? 0; + return { id: driver.id, name: driver.name, - rating: ranking?.rating ?? 0, - skillLevel: 'Pro', // TODO: map from domain + rating, + // Use core SkillLevelService to derive band from rating + skillLevel: SkillLevelService.getSkillLevel(rating), nationality: driver.country, - racesCompleted: stats?.racesCompleted ?? 0, + racesCompleted, wins: stats?.wins ?? 0, podiums: stats?.podiums ?? 0, - isActive: true, // TODO: determine from domain + // Consider a driver active if they have completed at least one race + isActive: racesCompleted > 0, rank: ranking?.overallRank ?? 0, avatarUrl, }; diff --git a/apps/api/src/domain/league/LeagueProviders.ts b/apps/api/src/domain/league/LeagueProviders.ts index 4949404ff..431df9199 100644 --- a/apps/api/src/domain/league/LeagueProviders.ts +++ b/apps/api/src/domain/league/LeagueProviders.ts @@ -3,12 +3,18 @@ import { LeagueService } from './LeagueService'; // Import core interfaces import type { Logger } from '@core/shared/application/Logger'; +import type { ISeasonSponsorshipRepository } from '@core/racing/domain/repositories/ISeasonSponsorshipRepository'; +import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository'; +import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository'; +import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository'; +import type { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository'; // Import concrete in-memory implementations import { InMemoryLeagueRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueRepository'; import { InMemoryLeagueMembershipRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueMembershipRepository'; import { InMemoryLeagueStandingsRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueStandingsRepository'; import { InMemorySeasonRepository } from '@adapters/racing/persistence/inmemory/InMemorySeasonRepository'; +import { InMemorySeasonSponsorshipRepository } from '@adapters/racing/persistence/inmemory/InMemorySeasonSponsorshipRepository'; import { InMemoryLeagueScoringConfigRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueScoringConfigRepository'; import { InMemoryGameRepository } from '@adapters/racing/persistence/inmemory/InMemoryGameRepository'; import { InMemoryProtestRepository } from '@adapters/racing/persistence/inmemory/InMemoryProtestRepository'; @@ -25,6 +31,7 @@ import { GetAllLeaguesWithCapacityUseCase } from '@core/racing/application/use-c import { GetLeagueStandingsUseCase } from '@core/league/application/use-cases/GetLeagueStandingsUseCase'; import { GetLeagueStandingsUseCaseImpl } from '@core/league/application/use-cases/GetLeagueStandingsUseCaseImpl'; import { GetLeagueFullConfigUseCase } from '@core/racing/application/use-cases/GetLeagueFullConfigUseCase'; +import { GetSeasonSponsorshipsUseCase } from '@core/racing/application/use-cases/GetSeasonSponsorshipsUseCase'; import { CreateLeagueWithSeasonAndScoringUseCase } from '@core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase'; import { GetRaceProtestsUseCase } from '@core/racing/application/use-cases/GetRaceProtestsUseCase'; import { GetTotalLeaguesUseCase } from '@core/racing/application/use-cases/GetTotalLeaguesUseCase'; @@ -86,6 +93,11 @@ export const LeagueProviders: Provider[] = [ useFactory: (logger: Logger) => new InMemorySeasonRepository(logger), // Factory for InMemorySeasonRepository inject: [LOGGER_TOKEN], }, + { + provide: 'ISeasonSponsorshipRepository', + useFactory: (logger: Logger) => new InMemorySeasonSponsorshipRepository(logger), + inject: [LOGGER_TOKEN], + }, { provide: LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN, useFactory: (logger: Logger) => new InMemoryLeagueScoringConfigRepository(logger), // Factory for InMemoryLeagueScoringConfigRepository @@ -150,6 +162,23 @@ export const LeagueProviders: Provider[] = [ GetLeagueAdminPermissionsUseCase, GetLeagueWalletUseCase, WithdrawFromLeagueWalletUseCase, + { + provide: GetSeasonSponsorshipsUseCase, + useFactory: ( + seasonSponsorshipRepo: ISeasonSponsorshipRepository, + seasonRepo: ISeasonRepository, + leagueRepo: ILeagueRepository, + leagueMembershipRepo: ILeagueMembershipRepository, + raceRepo: IRaceRepository, + ) => new GetSeasonSponsorshipsUseCase(seasonSponsorshipRepo, seasonRepo, leagueRepo, leagueMembershipRepo, raceRepo), + inject: [ + 'ISeasonSponsorshipRepository', + SEASON_REPOSITORY_TOKEN, + LEAGUE_REPOSITORY_TOKEN, + LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, + RACE_REPOSITORY_TOKEN, + ], + }, { provide: ListLeagueScoringPresetsUseCase, useFactory: () => new ListLeagueScoringPresetsUseCase(listLeagueScoringPresets()), diff --git a/apps/api/src/domain/league/LeagueService.test.ts b/apps/api/src/domain/league/LeagueService.test.ts index 2764c5e9a..eb08ff934 100644 --- a/apps/api/src/domain/league/LeagueService.test.ts +++ b/apps/api/src/domain/league/LeagueService.test.ts @@ -1,8 +1,12 @@ import { LeagueService } from './LeagueService'; import { GetAllLeaguesWithCapacityUseCase } from '@core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase'; -import { GetLeagueStandingsUseCase } from '@core/racing/application/use-cases/GetLeagueStandingsUseCase'; +import { GetLeagueStandingsUseCase } from '@core/league/application/use-cases/GetLeagueStandingsUseCase'; import { GetLeagueStatsUseCase } from '@core/racing/application/use-cases/GetLeagueStatsUseCase'; import { GetLeagueFullConfigUseCase } from '@core/racing/application/use-cases/GetLeagueFullConfigUseCase'; +import { GetLeagueScoringConfigUseCase } from '@core/racing/application/use-cases/GetLeagueScoringConfigUseCase'; +import { ListLeagueScoringPresetsUseCase } from '@core/racing/application/use-cases/ListLeagueScoringPresetsUseCase'; +import { JoinLeagueUseCase } from '@core/racing/application/use-cases/JoinLeagueUseCase'; +import { TransferLeagueOwnershipUseCase } from '@core/racing/application/use-cases/TransferLeagueOwnershipUseCase'; import { CreateLeagueWithSeasonAndScoringUseCase } from '@core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase'; import { GetRaceProtestsUseCase } from '@core/racing/application/use-cases/GetRaceProtestsUseCase'; import { GetTotalLeaguesUseCase } from '@core/racing/application/use-cases/GetTotalLeaguesUseCase'; @@ -13,6 +17,13 @@ import { RemoveLeagueMemberUseCase } from '@core/racing/application/use-cases/Re import { UpdateLeagueMemberRoleUseCase } from '@core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase'; import { GetLeagueOwnerSummaryUseCase } from '@core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase'; import { GetLeagueProtestsUseCase } from '@core/racing/application/use-cases/GetLeagueProtestsUseCase'; +import { GetLeagueSeasonsUseCase } from '@core/racing/application/use-cases/GetLeagueSeasonsUseCase'; +import { GetLeagueMembershipsUseCase } from '@core/racing/application/use-cases/GetLeagueMembershipsUseCase'; +import { GetLeagueScheduleUseCase } from '@core/racing/application/use-cases/GetLeagueScheduleUseCase'; +import { GetLeagueAdminPermissionsUseCase } from '@core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase'; +import { GetLeagueWalletUseCase } from '@core/racing/application/use-cases/GetLeagueWalletUseCase'; +import { WithdrawFromLeagueWalletUseCase } from '@core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase'; +import { GetSeasonSponsorshipsUseCase } from '@core/racing/application/use-cases/GetSeasonSponsorshipsUseCase'; import type { Logger } from '@core/shared/application/Logger'; import { Result } from '@core/shared/application/Result'; @@ -21,41 +32,58 @@ describe('LeagueService', () => { let mockGetTotalLeaguesUseCase: jest.Mocked; let mockGetLeagueJoinRequestsUseCase: jest.Mocked; let mockApproveLeagueJoinRequestUseCase: jest.Mocked; + let mockGetLeagueFullConfigUseCase: jest.Mocked; + let mockGetLeagueOwnerSummaryUseCase: jest.Mocked; + let mockGetLeagueScheduleUseCase: jest.Mocked; + let mockGetSeasonSponsorshipsUseCase: 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; + const createUseCaseMock = (): jest.Mocked => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + execute: jest.fn() as any, + }) as jest.Mocked; + + mockGetTotalLeaguesUseCase = createUseCaseMock(); + mockGetLeagueJoinRequestsUseCase = createUseCaseMock(); + mockApproveLeagueJoinRequestUseCase = createUseCaseMock(); + mockGetLeagueFullConfigUseCase = createUseCaseMock(); + mockGetLeagueOwnerSummaryUseCase = createUseCaseMock(); + mockGetLeagueScheduleUseCase = createUseCaseMock(); + mockGetSeasonSponsorshipsUseCase = createUseCaseMock(); mockLogger = { debug: jest.fn(), - } as any; + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } as unknown as jest.Mocked; service = new LeagueService( - {} as any, // mockGetAllLeaguesWithCapacityUseCase - {} as any, // mockGetLeagueStandingsUseCase - {} as any, // mockGetLeagueStatsUseCase - {} as any, // mockGetLeagueFullConfigUseCase - {} as any, // mockCreateLeagueWithSeasonAndScoringUseCase - {} as any, // mockGetRaceProtestsUseCase + {} as unknown as GetAllLeaguesWithCapacityUseCase, + {} as unknown as GetLeagueStandingsUseCase, + {} as unknown as GetLeagueStatsUseCase, + mockGetLeagueFullConfigUseCase, + {} as unknown as GetLeagueScoringConfigUseCase, + {} as unknown as ListLeagueScoringPresetsUseCase, + {} as unknown as JoinLeagueUseCase, + {} as unknown as TransferLeagueOwnershipUseCase, + {} as unknown as CreateLeagueWithSeasonAndScoringUseCase, + {} as unknown as GetRaceProtestsUseCase, 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 + {} as unknown as RejectLeagueJoinRequestUseCase, + {} as unknown as RemoveLeagueMemberUseCase, + {} as unknown as UpdateLeagueMemberRoleUseCase, + mockGetLeagueOwnerSummaryUseCase, + {} as unknown as GetLeagueProtestsUseCase, + {} as unknown as GetLeagueSeasonsUseCase, + {} as unknown as GetLeagueMembershipsUseCase, + mockGetLeagueScheduleUseCase, + {} as unknown as GetLeagueAdminPermissionsUseCase, + {} as unknown as GetLeagueWalletUseCase, + {} as unknown as WithdrawFromLeagueWalletUseCase, + mockGetSeasonSponsorshipsUseCase, mockLogger, ); }); @@ -70,7 +98,7 @@ describe('LeagueService', () => { }); it('should get league join requests', async () => { - mockGetLeagueJoinRequestsUseCase.execute.mockImplementation(async (params, presenter) => { + 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' }], @@ -79,18 +107,20 @@ describe('LeagueService', () => { 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' }, - }]); + 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) => { + mockApproveLeagueJoinRequestUseCase.execute.mockImplementation(async (_params, presenter) => { presenter.present({ success: true, message: 'Join request approved.' }); }); @@ -100,70 +130,200 @@ describe('LeagueService', () => { }); 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; + const mockRejectUseCase: jest.Mocked = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + execute: jest.fn() as any, + } as unknown as jest.Mocked; service = new LeagueService( - {} as any, - {} as any, - {} as any, - {} as any, - {} as any, - {} as any, - {} as any, - {} as any, - {} as any, + {} as unknown as GetAllLeaguesWithCapacityUseCase, + {} as unknown as GetLeagueStandingsUseCase, + {} as unknown as GetLeagueStatsUseCase, + mockGetLeagueFullConfigUseCase, + {} as unknown as GetLeagueScoringConfigUseCase, + {} as unknown as ListLeagueScoringPresetsUseCase, + {} as unknown as JoinLeagueUseCase, + {} as unknown as TransferLeagueOwnershipUseCase, + {} as unknown as CreateLeagueWithSeasonAndScoringUseCase, + {} as unknown as GetRaceProtestsUseCase, + mockGetTotalLeaguesUseCase, + mockGetLeagueJoinRequestsUseCase, + mockApproveLeagueJoinRequestUseCase, mockRejectUseCase, - {} as any, - {} as any, - {} as any, - {} as any, - {} as any, - {} as any, - {} as any, - {} as any, + {} as unknown as RemoveLeagueMemberUseCase, + {} as unknown as UpdateLeagueMemberRoleUseCase, + mockGetLeagueOwnerSummaryUseCase, + {} as unknown as GetLeagueProtestsUseCase, + {} as unknown as GetLeagueSeasonsUseCase, + {} as unknown as GetLeagueMembershipsUseCase, + {} as unknown as GetLeagueScheduleUseCase, + {} as unknown as GetLeagueAdminPermissionsUseCase, + {} as unknown as GetLeagueWalletUseCase, + {} as unknown as WithdrawFromLeagueWalletUseCase, mockLogger, ); + mockRejectUseCase.execute.mockImplementation(async (_params, presenter) => { + presenter.present({ success: true, message: 'Join request rejected.' }); + }); + 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; + const mockRemoveUseCase: jest.Mocked = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + execute: jest.fn() as any, + } as unknown as jest.Mocked; service = new LeagueService( - {} as any, - {} as any, - {} as any, - {} as any, - {} as any, - {} as any, - {} as any, - {} as any, - {} as any, - {} as any, + {} as unknown as GetAllLeaguesWithCapacityUseCase, + {} as unknown as GetLeagueStandingsUseCase, + {} as unknown as GetLeagueStatsUseCase, + mockGetLeagueFullConfigUseCase, + {} as unknown as GetLeagueScoringConfigUseCase, + {} as unknown as ListLeagueScoringPresetsUseCase, + {} as unknown as JoinLeagueUseCase, + {} as unknown as TransferLeagueOwnershipUseCase, + {} as unknown as CreateLeagueWithSeasonAndScoringUseCase, + {} as unknown as GetRaceProtestsUseCase, + mockGetTotalLeaguesUseCase, + mockGetLeagueJoinRequestsUseCase, + mockApproveLeagueJoinRequestUseCase, + {} as unknown as RejectLeagueJoinRequestUseCase, mockRemoveUseCase, - {} as any, - {} as any, - {} as any, - {} as any, - {} as any, - {} as any, - {} as any, + {} as unknown as UpdateLeagueMemberRoleUseCase, + mockGetLeagueOwnerSummaryUseCase, + {} as unknown as GetLeagueProtestsUseCase, + {} as unknown as GetLeagueSeasonsUseCase, + {} as unknown as GetLeagueMembershipsUseCase, + {} as unknown as GetLeagueScheduleUseCase, + {} as unknown as GetLeagueAdminPermissionsUseCase, + {} as unknown as GetLeagueWalletUseCase, + {} as unknown as WithdrawFromLeagueWalletUseCase, mockLogger, ); + mockRemoveUseCase.execute.mockImplementation(async (_params, presenter) => { + presenter.present({ success: true }); + }); + const result = await service.removeLeagueMember({ leagueId: 'league-1', performerDriverId: 'performer-1', targetDriverId: 'driver-1' }); expect(result).toEqual({ success: true }); }); + + it('should aggregate league admin data via composite use case', async () => { + const fullConfig = { + league: { + id: 'league-1', + name: 'Test League', + description: 'Test', + ownerId: 'owner-1', + settings: { pointsSystem: 'custom' }, + }, + } as any; + + mockGetLeagueFullConfigUseCase.execute.mockResolvedValue(Result.ok(fullConfig)); + mockGetLeagueOwnerSummaryUseCase.execute.mockResolvedValue(Result.ok({ summary: null } as any)); + + const joinRequestsSpy = jest + .spyOn(service, 'getLeagueJoinRequests') + .mockResolvedValue({ joinRequests: [] } as any); + const protestsSpy = jest + .spyOn(service, 'getLeagueProtests') + .mockResolvedValue({ protests: [], racesById: {}, driversById: {} } as any); + const seasonsSpy = jest + .spyOn(service, 'getLeagueSeasons') + .mockResolvedValue([]); + + const result = await service.getLeagueAdmin('league-1'); + + expect(mockGetLeagueFullConfigUseCase.execute).toHaveBeenCalledWith({ leagueId: 'league-1' }); + expect(mockGetLeagueOwnerSummaryUseCase.execute).toHaveBeenCalledWith({ ownerId: 'owner-1' }); + expect(joinRequestsSpy).toHaveBeenCalledWith('league-1'); + expect(protestsSpy).toHaveBeenCalledWith({ leagueId: 'league-1' }); + expect(seasonsSpy).toHaveBeenCalledWith({ leagueId: 'league-1' }); + expect(result.config.form?.leagueId).toBe('league-1'); + }); + + it('should get season sponsorships', async () => { + const sponsorship = { + id: 's-1', + leagueId: 'league-1', + leagueName: 'League 1', + seasonId: 'season-123', + seasonName: 'Season 1', + tier: 'gold', + status: 'active', + pricing: { + amount: 1000, + currency: 'USD', + }, + platformFee: { + amount: 100, + currency: 'USD', + }, + netAmount: { + amount: 900, + currency: 'USD', + }, + metrics: { + drivers: 10, + races: 5, + completedRaces: 3, + impressions: 3000, + }, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + } as any; + + mockGetSeasonSponsorshipsUseCase.execute.mockResolvedValue( + Result.ok({ + seasonId: 'season-123', + sponsorships: [sponsorship], + }), + ); + + const result = await service.getSeasonSponsorships('season-123'); + + expect(mockGetSeasonSponsorshipsUseCase.execute).toHaveBeenCalledWith({ seasonId: 'season-123' }); + expect(result.sponsorships).toHaveLength(1); + expect(result.sponsorships[0]).toMatchObject({ + id: 's-1', + leagueId: 'league-1', + leagueName: 'League 1', + seasonId: 'season-123', + seasonName: 'Season 1', + tier: 'gold', + }); + }); + + it('should get races for league', async () => { + const scheduledAt = new Date('2024-02-01T12:00:00.000Z'); + + mockGetLeagueScheduleUseCase.execute.mockResolvedValue( + Result.ok({ + races: [ + { + id: 'race-1', + name: 'Race 1', + scheduledAt, + }, + ], + }), + ); + + const result = await service.getRaces('league-123'); + + expect(mockGetLeagueScheduleUseCase.execute).toHaveBeenCalledWith({ leagueId: 'league-123' }); + expect(result.races).toHaveLength(1); + expect(result.races[0]).toMatchObject({ + id: 'race-1', + name: 'Race 1', + date: scheduledAt.toISOString(), + leagueName: undefined, + }); + }); }); \ No newline at end of file diff --git a/apps/api/src/domain/league/LeagueService.ts b/apps/api/src/domain/league/LeagueService.ts index 0d827cd5d..8989219f8 100644 --- a/apps/api/src/domain/league/LeagueService.ts +++ b/apps/api/src/domain/league/LeagueService.ts @@ -60,6 +60,7 @@ import { TransferLeagueOwnershipUseCase } from '@core/racing/application/use-cas import { UpdateLeagueMemberRoleUseCase } from '@core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase'; import { GetLeagueWalletUseCase } from '@core/racing/application/use-cases/GetLeagueWalletUseCase'; import { WithdrawFromLeagueWalletUseCase } from '@core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase'; +import { GetSeasonSponsorshipsUseCase } from '@core/racing/application/use-cases/GetSeasonSponsorshipsUseCase'; // API Presenters import { AllLeaguesWithCapacityPresenter } from './presenters/AllLeaguesWithCapacityPresenter'; @@ -70,7 +71,7 @@ import { mapApproveLeagueJoinRequestPortToDTO } from './presenters/ApproveLeague import { GetLeagueAdminPermissionsPresenter } from './presenters/GetLeagueAdminPermissionsPresenter'; import { mapGetLeagueOwnerSummaryOutputPortToDTO } from './presenters/GetLeagueOwnerSummaryPresenter'; import { LeagueJoinRequestsPresenter } from './presenters/LeagueJoinRequestsPresenter'; -import { mapGetLeagueScheduleOutputPortToDTO } from './presenters/LeagueSchedulePresenter'; +import { mapGetLeagueScheduleOutputPortToDTO, mapGetLeagueScheduleOutputPortToRaceDTOs } from './presenters/LeagueSchedulePresenter'; import { LeagueStatsPresenter } from './presenters/LeagueStatsPresenter'; import { mapRejectLeagueJoinRequestOutputPortToDTO } from './presenters/RejectLeagueJoinRequestPresenter'; import { mapRemoveLeagueMemberOutputPortToDTO } from './presenters/RemoveLeagueMemberPresenter'; @@ -112,6 +113,7 @@ export class LeagueService { private readonly getLeagueAdminPermissionsUseCase: GetLeagueAdminPermissionsUseCase, private readonly getLeagueWalletUseCase: GetLeagueWalletUseCase, private readonly withdrawFromLeagueWalletUseCase: WithdrawFromLeagueWalletUseCase, + private readonly getSeasonSponsorshipsUseCase: GetSeasonSponsorshipsUseCase, @Inject(LOGGER_TOKEN) private readonly logger: Logger, ) {} @@ -263,11 +265,21 @@ export class LeagueService { async getLeagueSchedule(leagueId: string): Promise { this.logger.debug('Getting league schedule', { leagueId }); - const result = await this.getLeagueScheduleUseCase.execute({ leagueId }); - if (result.isErr()) { - throw new Error(result.unwrapErr().code); + + const [scheduleResult, leagueConfigResult] = await Promise.all([ + this.getLeagueScheduleUseCase.execute({ leagueId }), + this.getLeagueFullConfigUseCase.execute({ leagueId }), + ]); + + if (scheduleResult.isErr()) { + throw new Error(scheduleResult.unwrapErr().code); } - return mapGetLeagueScheduleOutputPortToDTO(result.unwrap()); + + const leagueName = leagueConfigResult.isOk() + ? leagueConfigResult.unwrap().league.name.toString() + : undefined; + + return mapGetLeagueScheduleOutputPortToDTO(scheduleResult.unwrap(), leagueName); } async getLeagueStats(leagueId: string): Promise { @@ -281,64 +293,49 @@ export class LeagueService { 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 }); + private async getLeagueAdminComposite(leagueId: string): Promise { + this.logger.debug('Fetching composite league admin data', { 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; + const [fullConfigResult, joinRequests, protests, seasons] = await Promise.all([ + this.getLeagueFullConfigUseCase.execute({ leagueId }), + this.getLeagueJoinRequests(leagueId), + this.getLeagueProtests({ leagueId }), + this.getLeagueSeasons({ leagueId }), + ]); - // Convert config from view model to DTO format manually with proper types - const configForm = config ? { - leagueId: config.leagueId, - basics: { - name: config.basics.name, - description: config.basics.description, - visibility: config.basics.visibility as 'public' | 'private', - }, - structure: { - mode: config.structure.mode as 'solo' | 'team', - }, - championships: [], // TODO: Map championships from view model - scoring: { - type: 'standard' as const, // TODO: Map from view model - points: 25, // TODO: Map from view model - }, - dropPolicy: { - strategy: config.dropPolicy.strategy as 'none' | 'worst_n', - n: config.dropPolicy.n ?? 0, - }, - timings: { - raceDayOfWeek: 'sunday' as const, // TODO: Map from view model - raceTimeHour: 20, // TODO: Map from view model - raceTimeMinute: 0, // TODO: Map from view model - }, - stewarding: { - decisionMode: config.stewarding.decisionMode === 'steward_vote' ? 'committee_vote' as const : 'single_steward' as const, - requireDefense: config.stewarding.requireDefense, - defenseTimeLimit: config.stewarding.defenseTimeLimit, - voteTimeLimit: config.stewarding.voteTimeLimit, - protestDeadlineHours: config.stewarding.protestDeadlineHours, - stewardingClosesHours: config.stewarding.stewardingClosesHours, - notifyAccusedOnProtest: config.stewarding.notifyAccusedOnProtest, - notifyOnVoteRequired: config.stewarding.notifyOnVoteRequired, - requiredVotes: config.stewarding.requiredVotes ?? 0, - }, - } : null; + if (fullConfigResult.isErr()) { + throw new Error(fullConfigResult.unwrapErr().code); + } - return { + const fullConfig = fullConfigResult.unwrap(); + const league = fullConfig.league; + + const ownerSummaryResult = await this.getLeagueOwnerSummaryUseCase.execute({ ownerId: league.ownerId.toString() }); + if (ownerSummaryResult.isErr()) { + throw new Error(ownerSummaryResult.unwrapErr().code); + } + + const ownerSummary = mapGetLeagueOwnerSummaryOutputPortToDTO(ownerSummaryResult.unwrap()); + + const configPresenter = new LeagueConfigPresenter(); + configPresenter.present(fullConfig); + const configForm = configPresenter.getViewModel(); + + const adminPresenter = new LeagueAdminPresenter(); + adminPresenter.present({ joinRequests: joinRequests.joinRequests, - ownerSummary: ownerSummary?.summary || null, - config: { form: configForm }, + ownerSummary, + config: configForm, protests, seasons, - }; + }); + + return adminPresenter.getViewModel(); + } + + async getLeagueAdmin(leagueId: string): Promise { + this.logger.debug('Getting league admin data', { leagueId }); + return this.getLeagueAdminComposite(leagueId); } async createLeague(input: CreateLeagueInputDTO): Promise { @@ -426,20 +423,30 @@ export class LeagueService { async getSeasonSponsorships(seasonId: string): Promise { this.logger.debug('Getting season sponsorships', { seasonId }); - // TODO: Implement actual logic to fetch season sponsorships - // For now, return empty array as placeholder + const result = await this.getSeasonSponsorshipsUseCase.execute({ seasonId }); + if (result.isErr()) { + throw new Error(result.unwrapErr().code); + } + + const value = result.unwrap(); + return { - sponsorships: [], + sponsorships: value?.sponsorships ?? [], }; } - + async getRaces(leagueId: string): Promise { this.logger.debug('Getting league races', { leagueId }); - // TODO: Implement actual logic to fetch league races - // For now, return empty array as placeholder + const result = await this.getLeagueScheduleUseCase.execute({ leagueId }); + if (result.isErr()) { + throw new Error(result.unwrapErr().code); + } + + const races = mapGetLeagueScheduleOutputPortToRaceDTOs(result.unwrap()); + return { - races: [], + races, }; } diff --git a/apps/api/src/domain/league/dtos/ProtestDTO.ts b/apps/api/src/domain/league/dtos/ProtestDTO.ts index 76fc53d8a..78252ef1b 100644 --- a/apps/api/src/domain/league/dtos/ProtestDTO.ts +++ b/apps/api/src/domain/league/dtos/ProtestDTO.ts @@ -2,13 +2,23 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsString, IsDate, IsEnum } from 'class-validator'; import { Type } from 'class-transformer'; -// TODO: protests are filed at race level but also managed on league level - +/** + * ProtestDTO represents a protest that is filed against a specific race + * but is queried and managed in a league context. + * + * Both `leagueId` and `raceId` are exposed so that API consumers can + * clearly relate the protest back to the league admin view while still + * understanding which concrete race it belongs to. + */ export class ProtestDTO { @ApiProperty() @IsString() id: string; + @ApiProperty() + @IsString() + leagueId: string; + @ApiProperty() @IsString() raceId: string; diff --git a/apps/api/src/domain/league/presenters/GetLeagueProtestsPresenter.ts b/apps/api/src/domain/league/presenters/GetLeagueProtestsPresenter.ts index c68759b58..2bd5349d4 100644 --- a/apps/api/src/domain/league/presenters/GetLeagueProtestsPresenter.ts +++ b/apps/api/src/domain/league/presenters/GetLeagueProtestsPresenter.ts @@ -1,28 +1,49 @@ -import { GetLeagueProtestsOutputPort } from '@core/racing/application/ports/output/GetLeagueProtestsOutputPort'; +import { GetLeagueProtestsOutputPort, type ProtestOutputPort } from '@core/racing/application/ports/output/GetLeagueProtestsOutputPort'; import { LeagueAdminProtestsDTO } from '../dtos/LeagueAdminProtestsDTO'; import { ProtestDTO } from '../dtos/ProtestDTO'; import { RaceDTO } from '../../race/dtos/RaceDTO'; import { DriverDTO } from '../../driver/dtos/DriverDTO'; -export function mapGetLeagueProtestsOutputPortToDTO(output: GetLeagueProtestsOutputPort): LeagueAdminProtestsDTO { - const protests: ProtestDTO[] = output.protests.map(protest => ({ - id: protest.id, - raceId: protest.raceId, - protestingDriverId: protest.protestingDriverId, - accusedDriverId: protest.accusedDriverId, - submittedAt: new Date(protest.filedAt), - description: protest.incident.description, - status: protest.status as 'pending' | 'accepted' | 'rejected', // TODO: map properly - })); +function mapProtestStatus(status: ProtestOutputPort['status']): ProtestDTO['status'] { + switch (status) { + case 'pending': + case 'awaiting_defense': + case 'under_review': + return 'pending'; + case 'upheld': + return 'accepted'; + case 'dismissed': + case 'withdrawn': + return 'rejected'; + default: + return 'pending'; + } +} + +export function mapGetLeagueProtestsOutputPortToDTO(output: GetLeagueProtestsOutputPort, leagueName?: string): LeagueAdminProtestsDTO { + const protests: ProtestDTO[] = output.protests.map((protest) => { + const race = output.racesById[protest.raceId]; + + return { + id: protest.id, + leagueId: race?.leagueId, + raceId: protest.raceId, + protestingDriverId: protest.protestingDriverId, + accusedDriverId: protest.accusedDriverId, + submittedAt: new Date(protest.filedAt), + description: protest.incident.description, + status: mapProtestStatus(protest.status), + }; + }); const racesById: { [raceId: string]: RaceDTO } = {}; for (const raceId in output.racesById) { const race = output.racesById[raceId]; racesById[raceId] = { id: race.id, - name: race.track, // assuming name is track + name: race.track, date: race.scheduledAt, - leagueName: undefined, // TODO: get league name if needed + leagueName, }; } diff --git a/apps/api/src/domain/league/presenters/LeagueConfigPresenter.test.ts b/apps/api/src/domain/league/presenters/LeagueConfigPresenter.test.ts new file mode 100644 index 000000000..38a1dad87 --- /dev/null +++ b/apps/api/src/domain/league/presenters/LeagueConfigPresenter.test.ts @@ -0,0 +1,83 @@ +import { LeagueConfigPresenter } from './LeagueConfigPresenter'; +import type { LeagueFullConfigOutputPort } from '@core/racing/application/ports/output/LeagueFullConfigOutputPort'; + +describe('LeagueConfigPresenter', () => { + const createFullConfig = (overrides: Partial = {}): LeagueFullConfigOutputPort => { + const base: LeagueFullConfigOutputPort = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + league: { + id: 'league-1', + name: 'Test League', + description: 'Desc', + ownerId: 'owner-1', + settings: { pointsSystem: 'custom' }, + createdAt: new Date(), + } as any, + activeSeason: { + id: 'season-1', + leagueId: 'league-1', + gameId: 'iracing', + name: 'Season 1', + status: 'planned', + schedule: { + startDate: new Date('2025-01-05T19:00:00Z'), + timeOfDay: { hour: 20, minute: 0 } as any, + } as any, + dropPolicy: { strategy: 'bestNResults', n: 3 } as any, + stewardingConfig: { + decisionMode: 'steward_vote', + requiredVotes: 3, + requireDefense: true, + defenseTimeLimit: 24, + voteTimeLimit: 24, + protestDeadlineHours: 48, + stewardingClosesHours: 72, + notifyAccusedOnProtest: true, + notifyOnVoteRequired: true, + } as any, + } as any, + scoringConfig: { + id: 'scoring-1', + seasonId: 'season-1', + championships: [ + { + id: 'champ-1', + name: 'Drivers', + type: 'driver' as any, + sessionTypes: ['race'] as any, + pointsTableBySessionType: { + race: { + getPointsForPosition: (pos: number) => (pos === 1 ? 25 : 0), + } as any, + }, + dropScorePolicy: { strategy: 'bestNResults', count: 3 } as any, + }, + ], + } as any, + game: undefined, + ...overrides, + }; + + return base; + }; + + it('maps league config into form model with scoring and timings', () => { + const presenter = new LeagueConfigPresenter(); + const fullConfig = createFullConfig(); + + presenter.present(fullConfig); + const vm = presenter.getViewModel(); + + expect(vm).not.toBeNull(); + expect(vm!.leagueId).toBe('league-1'); + expect(vm!.basics.name).toBe('Test League'); + expect(vm!.scoring.type).toBe('custom'); + expect(vm!.scoring.points).toBe(25); + expect(vm!.championships.length).toBe(1); + expect(vm!.timings.raceTimeHour).toBe(20); + expect(vm!.timings.raceTimeMinute).toBe(0); + expect(vm!.dropPolicy.strategy).toBe('worst_n'); + expect(vm!.dropPolicy.n).toBe(3); + expect(vm!.stewarding.decisionMode).toBe('committee_vote'); + }); +}); diff --git a/apps/api/src/domain/league/presenters/LeagueConfigPresenter.ts b/apps/api/src/domain/league/presenters/LeagueConfigPresenter.ts index 0ec0aa3f3..e6c13e895 100644 --- a/apps/api/src/domain/league/presenters/LeagueConfigPresenter.ts +++ b/apps/api/src/domain/league/presenters/LeagueConfigPresenter.ts @@ -10,45 +10,64 @@ export class LeagueConfigPresenter implements Presenter ({ + races: output.races.map(race => ({ id: race.id, name: race.name, date: race.scheduledAt.toISOString(), - leagueName: undefined, // TODO: get league name if needed + leagueName, })), }; +} + +export function mapGetLeagueScheduleOutputPortToRaceDTOs(output: GetLeagueScheduleOutputPort, leagueName?: string): RaceDTO[] { + return output.races.map(race => ({ + id: race.id, + name: race.name, + date: race.scheduledAt.toISOString(), + leagueName, + })); } \ No newline at end of file diff --git a/apps/api/src/domain/race/presenters/GetAllRacesPresenter.test.ts b/apps/api/src/domain/race/presenters/GetAllRacesPresenter.test.ts new file mode 100644 index 000000000..94cbb62fb --- /dev/null +++ b/apps/api/src/domain/race/presenters/GetAllRacesPresenter.test.ts @@ -0,0 +1,75 @@ +import { GetAllRacesPresenter } from './GetAllRacesPresenter'; +import type { GetAllRacesOutputPort } from '@core/racing/application/ports/output/GetAllRacesOutputPort'; + +describe('GetAllRacesPresenter', () => { + it('should map races and distinct leagues into the DTO', async () => { + const presenter = new GetAllRacesPresenter(); + + const output: GetAllRacesOutputPort = { + races: [ + { + id: 'race-1', + leagueId: 'league-1', + track: 'Track A', + car: 'Car A', + status: 'scheduled', + scheduledAt: '2025-01-01T10:00:00.000Z', + strengthOfField: 1500, + leagueName: 'League One', + }, + { + id: 'race-2', + leagueId: 'league-1', + track: 'Track B', + car: 'Car B', + status: 'completed', + scheduledAt: '2025-01-02T10:00:00.000Z', + strengthOfField: null, + leagueName: 'League One', + }, + { + id: 'race-3', + leagueId: 'league-2', + track: 'Track C', + car: 'Car C', + status: 'running', + scheduledAt: '2025-01-03T10:00:00.000Z', + strengthOfField: 1800, + leagueName: 'League Two', + }, + ], + totalCount: 3, + }; + + await presenter.present(output); + const viewModel = presenter.getViewModel(); + + expect(viewModel).not.toBeNull(); + expect(viewModel!.races).toHaveLength(3); + + // Leagues should be distinct and match league ids/names from races + expect(viewModel!.filters.leagues).toEqual( + expect.arrayContaining([ + { id: 'league-1', name: 'League One' }, + { id: 'league-2', name: 'League Two' }, + ]), + ); + expect(viewModel!.filters.leagues).toHaveLength(2); + }); + + it('should handle empty races by returning empty leagues', async () => { + const presenter = new GetAllRacesPresenter(); + + const output: GetAllRacesOutputPort = { + races: [], + totalCount: 0, + }; + + await presenter.present(output); + const viewModel = presenter.getViewModel(); + + expect(viewModel).not.toBeNull(); + expect(viewModel!.races).toHaveLength(0); + expect(viewModel!.filters.leagues).toHaveLength(0); + }); +}) diff --git a/apps/api/src/domain/race/presenters/GetAllRacesPresenter.ts b/apps/api/src/domain/race/presenters/GetAllRacesPresenter.ts index 5ef65b969..1dfee75c5 100644 --- a/apps/api/src/domain/race/presenters/GetAllRacesPresenter.ts +++ b/apps/api/src/domain/race/presenters/GetAllRacesPresenter.ts @@ -9,6 +9,15 @@ export class GetAllRacesPresenter { } async present(output: GetAllRacesOutputPort) { + const uniqueLeagues = new Map(); + + for (const race of output.races) { + uniqueLeagues.set(race.leagueId, { + id: race.leagueId, + name: race.leagueName, + }); + } + this.result = { races: output.races.map(race => ({ id: race.id, @@ -28,7 +37,7 @@ export class GetAllRacesPresenter { { value: 'completed', label: 'Completed' }, { value: 'cancelled', label: 'Cancelled' }, ], - leagues: [], // TODO: populate if needed + leagues: Array.from(uniqueLeagues.values()), }, }; } diff --git a/apps/website/components/notifications/NotificationCenter.tsx b/apps/website/components/notifications/NotificationCenter.tsx index cf2fdb299..ea17413a5 100644 --- a/apps/website/components/notifications/NotificationCenter.tsx +++ b/apps/website/components/notifications/NotificationCenter.tsx @@ -1,7 +1,5 @@ 'use client'; -import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; -import type { Notification } from '@core/notifications/application'; import { AlertTriangle, Bell, @@ -36,33 +34,14 @@ const notificationColors: Record = { race_reminder: 'text-warning-amber bg-warning-amber/10', }; +import { useNotifications } from './NotificationProvider'; +import type { Notification } from './NotificationProvider'; + export default function NotificationCenter() { const [isOpen, setIsOpen] = useState(false); - const [notifications, setNotifications] = useState([]); - const [loading, setLoading] = useState(false); const panelRef = useRef(null); const router = useRouter(); - const currentDriverId = useEffectiveDriverId(); - - // Polling for new notifications - // TODO - // useEffect(() => { - // const loadNotifications = async () => { - // try { - // const repo = getNotificationRepository(); - // const allNotifications = await repo.findByRecipientId(currentDriverId); - // setNotifications(allNotifications); - // } catch (error) { - // console.error('Failed to load notifications:', error); - // } - // }; - - // loadNotifications(); - - // // Poll every 5 seconds - // const interval = setInterval(loadNotifications, 5000); - // return () => clearInterval(interval); - // }, [currentDriverId]); + const { notifications, unreadCount, markAsRead, markAllAsRead } = useNotifications(); // Close panel when clicking outside useEffect(() => { @@ -81,42 +60,9 @@ export default function NotificationCenter() { }; }, [isOpen]); - const unreadCount = notifications.filter((n) => n.isUnread()).length; + const handleNotificationClick = (notification: Notification) => { + markAsRead(notification.id); - const handleMarkAsRead = async (notification: Notification) => { - if (!notification.isUnread()) return; - - try { - const markRead = getMarkNotificationReadUseCase(); - await markRead.execute({ - notificationId: notification.id, - recipientId: currentDriverId, - }); - - // Update local state - setNotifications((prev) => - prev.map((n) => (n.id === notification.id ? n.markAsRead() : n)) - ); - } catch (error) { - console.error('Failed to mark notification as read:', error); - } - }; - - const handleMarkAllAsRead = async () => { - try { - const repo = getNotificationRepository(); - await repo.markAllAsReadByRecipientId(currentDriverId); - - // Update local state - setNotifications((prev) => prev.map((n) => n.markAsRead())); - } catch (error) { - console.error('Failed to mark all as read:', error); - } - }; - - const handleNotificationClick = async (notification: Notification) => { - await handleMarkAsRead(notification); - if (notification.actionUrl) { router.push(notification.actionUrl); setIsOpen(false); @@ -176,7 +122,7 @@ export default function NotificationCenter() { {unreadCount > 0 && (