resolve todos in website and api

This commit is contained in:
2025-12-20 10:45:56 +01:00
parent 656ec62426
commit 7bbad511e2
62 changed files with 2036 additions and 611 deletions

View File

@@ -137,6 +137,17 @@
"seasonName" "seasonName"
] ]
}, },
"SponsorshipDTO": {
"type": "object",
"properties": {
"id": {
"type": "string"
}
},
"required": [
"id"
]
},
"SponsoredLeagueDTO": { "SponsoredLeagueDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -167,6 +178,37 @@
"sponsorName" "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": { "SponsorDashboardMetricsDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -255,6 +297,21 @@
"name" "name"
] ]
}, },
"RenewalAlertDTO": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"id",
"name"
]
},
"RejectSponsorshipRequestInputDTO": { "RejectSponsorshipRequestInputDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -266,6 +323,144 @@
"respondedBy" "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": { "GetSponsorSponsorshipsQueryParamsDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -303,6 +498,44 @@
"entityId" "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": { "CreateSponsorInputDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -318,6 +551,75 @@
"contactEmail" "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": { "AcceptSponsorshipRequestInputDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -723,25 +1025,6 @@
"avatarUrl" "avatarUrl"
] ]
}, },
"RaceDTO": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"date": {
"type": "string"
}
},
"required": [
"id",
"name",
"date"
]
},
"RaceActionParamsDTO": { "RaceActionParamsDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -772,6 +1055,29 @@
"adminId" "adminId"
] ]
}, },
"ImportRaceResultsSummaryDTO": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"raceId": {
"type": "string"
},
"driversProcessed": {
"type": "number"
},
"resultsRecorded": {
"type": "number"
}
},
"required": [
"success",
"raceId",
"driversProcessed",
"resultsRecorded"
]
},
"ImportRaceResultsDTO": { "ImportRaceResultsDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -787,7 +1093,7 @@
"resultsFileContent" "resultsFileContent"
] ]
}, },
"GetRaceDetailParamsDTODTO": { "GetRaceDetailParamsDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
"raceId": { "raceId": {
@@ -891,6 +1197,12 @@
}, },
"scheduledAt": { "scheduledAt": {
"type": "string" "type": "string"
},
"status": {
"type": "string"
},
"isMyLeague": {
"type": "boolean"
} }
}, },
"required": [ "required": [
@@ -899,7 +1211,9 @@
"leagueName", "leagueName",
"track", "track",
"car", "car",
"scheduledAt" "scheduledAt",
"status",
"isMyLeague"
] ]
}, },
"DashboardLeagueStandingSummaryDTO": { "DashboardLeagueStandingSummaryDTO": {
@@ -1024,6 +1338,41 @@
"enum" "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": { "UpdatePaymentStatusInputDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1297,6 +1646,40 @@
"success" "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": { "UpdateLeagueMemberRoleOutputDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1327,6 +1710,17 @@
"targetDriverId" "targetDriverId"
] ]
}, },
"TotalLeaguesDTO": {
"type": "object",
"properties": {
"totalLeagues": {
"type": "number"
}
},
"required": [
"totalLeagues"
]
},
"SeasonDTO": { "SeasonDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1408,6 +1802,9 @@
"id": { "id": {
"type": "string" "type": "string"
}, },
"leagueId": {
"type": "string"
},
"raceId": { "raceId": {
"type": "string" "type": "string"
}, },
@@ -1427,6 +1824,7 @@
}, },
"required": [ "required": [
"id", "id",
"leagueId",
"raceId", "raceId",
"protestingDriverId", "protestingDriverId",
"accusedDriverId", "accusedDriverId",
@@ -1442,11 +1840,33 @@
}, },
"name": { "name": {
"type": "string" "type": "string"
},
"description": {
"type": "string"
},
"ownerId": {
"type": "string"
},
"settings": {
"type": "object"
},
"maxDrivers": {
"type": "number"
},
"sessionDuration": {
"type": "number"
},
"visibility": {
"type": "string"
} }
}, },
"required": [ "required": [
"id", "id",
"name" "name",
"description",
"ownerId",
"settings",
"maxDrivers"
] ]
}, },
"LeagueSummaryDTO": { "LeagueSummaryDTO": {
@@ -1513,6 +1933,44 @@
"status" "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": { "LeagueMemberDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1634,6 +2092,52 @@
"canUpdateRoles" "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": { "GetLeagueSeasonsQueryDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -2031,29 +2535,6 @@
"rank" "rank"
] ]
}, },
"DriverDTO": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"iracingId": {
"type": "string"
},
"name": {
"type": "string"
},
"country": {
"type": "string"
}
},
"required": [
"id",
"iracingId",
"name",
"country"
]
},
"CompleteOnboardingOutputDTO": { "CompleteOnboardingOutputDTO": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -10,7 +10,10 @@ import { DriverExtendedProfileProvider } from '@core/racing/application/ports/Dr
import { IImageServicePort } from '@core/racing/application/ports/IImageServicePort'; import { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
import { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository'; import { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
import { INotificationPreferenceRepository } from '@core/notifications/domain/repositories/INotificationPreferenceRepository'; 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 use cases
import { GetDriversLeaderboardUseCase } from '@core/racing/application/use-cases/GetDriversLeaderboardUseCase'; 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 { InMemoryImageServiceAdapter } from '@adapters/media/ports/InMemoryImageServiceAdapter';
import { InMemoryRaceRegistrationRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRegistrationRepository'; import { InMemoryRaceRegistrationRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRegistrationRepository';
import { InMemoryNotificationPreferenceRepository } from '@adapters/notifications/persistence/inmemory/InMemoryNotificationPreferenceRepository'; 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'; import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
// Define injection tokens // Define injection tokens
@@ -40,6 +46,9 @@ export const DRIVER_EXTENDED_PROFILE_PROVIDER_TOKEN = 'DriverExtendedProfileProv
export const IMAGE_SERVICE_PORT_TOKEN = 'IImageServicePort'; export const IMAGE_SERVICE_PORT_TOKEN = 'IImageServicePort';
export const RACE_REGISTRATION_REPOSITORY_TOKEN = 'IRaceRegistrationRepository'; export const RACE_REGISTRATION_REPOSITORY_TOKEN = 'IRaceRegistrationRepository';
export const NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN = 'INotificationPreferenceRepository'; 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 export const LOGGER_TOKEN = 'Logger'; // Already defined in AuthProviders, but good to have here too
// Use case tokens // Use case tokens
@@ -92,6 +101,22 @@ export const DriverProviders: Provider[] = [
useFactory: (logger: Logger) => new InMemoryNotificationPreferenceRepository(logger), useFactory: (logger: Logger) => new InMemoryNotificationPreferenceRepository(logger),
inject: [LOGGER_TOKEN], 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, provide: LOGGER_TOKEN,
useClass: ConsoleLogger, useClass: ConsoleLogger,
@@ -99,8 +124,13 @@ export const DriverProviders: Provider[] = [
// Use cases // Use cases
{ {
provide: GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN, provide: GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN,
useFactory: (driverRepo: IDriverRepository, rankingService: IRankingService, driverStatsService: IDriverStatsService, imageService: IImageServicePort, logger: Logger) => useFactory: (
new GetDriversLeaderboardUseCase(driverRepo, rankingService, driverStatsService, imageService, logger), 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], 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, 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], inject: [RACE_REGISTRATION_REPOSITORY_TOKEN, LOGGER_TOKEN],
}, },
{ {
@@ -125,18 +156,59 @@ export const DriverProviders: Provider[] = [
}, },
{ {
provide: GET_PROFILE_OVERVIEW_USE_CASE_TOKEN, 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( new GetProfileOverviewUseCase(
driverRepo, driverRepo,
// TODO: Add teamRepository, teamMembershipRepository, socialRepository, etc. teamRepository,
null as any, // teamRepository teamMembershipRepository,
null as any, // teamMembershipRepository socialRepository,
null as any, // socialRepository
imageService, imageService,
driverExtendedProfileProvider, driverExtendedProfileProvider,
() => null, // getDriverStats (driverId: string) => {
() => [], // getAllDriverRankings 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,
],
}, },
]; ];

View File

@@ -51,7 +51,7 @@ describe('DriversLeaderboardPresenter', () => {
id: 'driver-1', id: 'driver-1',
name: 'Driver One', name: 'Driver One',
rating: 2500, rating: 2500,
skillLevel: 'Pro', skillLevel: 'advanced',
nationality: 'US', nationality: 'US',
racesCompleted: 50, racesCompleted: 50,
wins: 10, wins: 10,
@@ -64,7 +64,7 @@ describe('DriversLeaderboardPresenter', () => {
id: 'driver-2', id: 'driver-2',
name: 'Driver Two', name: 'Driver Two',
rating: 2400, rating: 2400,
skillLevel: 'Pro', skillLevel: 'intermediate',
nationality: 'DE', nationality: 'DE',
racesCompleted: 40, racesCompleted: 40,
wins: 5, wins: 5,
@@ -137,6 +137,45 @@ describe('DriversLeaderboardPresenter', () => {
expect(result.drivers[0].racesCompleted).toBe(0); expect(result.drivers[0].racesCompleted).toBe(0);
expect(result.drivers[0].wins).toBe(0); expect(result.drivers[0].wins).toBe(0);
expect(result.drivers[0].podiums).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']);
}); });
}); });

View File

@@ -1,6 +1,7 @@
import { DriversLeaderboardDTO, DriverLeaderboardItemDTO } from '../dtos/DriversLeaderboardDTO'; import { DriversLeaderboardDTO } from '../dtos/DriversLeaderboardDTO';
import { DriverLeaderboardItemDTO } from '../dtos/DriverLeaderboardItemDTO'; import { DriverLeaderboardItemDTO } from '../dtos/DriverLeaderboardItemDTO';
import type { IDriversLeaderboardPresenter, DriversLeaderboardResultDTO } from '../../../../../core/racing/application/presenters/IDriversLeaderboardPresenter'; import type { IDriversLeaderboardPresenter, DriversLeaderboardResultDTO } from '../../../../../core/racing/application/presenters/IDriversLeaderboardPresenter';
import { SkillLevelService } from '../../../../../core/racing/domain/services/SkillLevelService';
export class DriversLeaderboardPresenter implements IDriversLeaderboardPresenter { export class DriversLeaderboardPresenter implements IDriversLeaderboardPresenter {
private result: DriversLeaderboardDTO | null = null; private result: DriversLeaderboardDTO | null = null;
@@ -15,16 +16,21 @@ export class DriversLeaderboardPresenter implements IDriversLeaderboardPresenter
const stats = dto.stats[driver.id]; const stats = dto.stats[driver.id];
const avatarUrl = dto.avatarUrls[driver.id]; const avatarUrl = dto.avatarUrls[driver.id];
const rating = ranking?.rating ?? 0;
const racesCompleted = stats?.racesCompleted ?? 0;
return { return {
id: driver.id, id: driver.id,
name: driver.name, name: driver.name,
rating: ranking?.rating ?? 0, rating,
skillLevel: 'Pro', // TODO: map from domain // Use core SkillLevelService to derive band from rating
skillLevel: SkillLevelService.getSkillLevel(rating),
nationality: driver.country, nationality: driver.country,
racesCompleted: stats?.racesCompleted ?? 0, racesCompleted,
wins: stats?.wins ?? 0, wins: stats?.wins ?? 0,
podiums: stats?.podiums ?? 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, rank: ranking?.overallRank ?? 0,
avatarUrl, avatarUrl,
}; };

View File

@@ -3,12 +3,18 @@ import { LeagueService } from './LeagueService';
// Import core interfaces // Import core interfaces
import type { Logger } from '@core/shared/application/Logger'; 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 concrete in-memory implementations
import { InMemoryLeagueRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueRepository'; import { InMemoryLeagueRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueRepository';
import { InMemoryLeagueMembershipRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueMembershipRepository'; import { InMemoryLeagueMembershipRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueMembershipRepository';
import { InMemoryLeagueStandingsRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueStandingsRepository'; import { InMemoryLeagueStandingsRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueStandingsRepository';
import { InMemorySeasonRepository } from '@adapters/racing/persistence/inmemory/InMemorySeasonRepository'; 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 { InMemoryLeagueScoringConfigRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueScoringConfigRepository';
import { InMemoryGameRepository } from '@adapters/racing/persistence/inmemory/InMemoryGameRepository'; import { InMemoryGameRepository } from '@adapters/racing/persistence/inmemory/InMemoryGameRepository';
import { InMemoryProtestRepository } from '@adapters/racing/persistence/inmemory/InMemoryProtestRepository'; 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 { GetLeagueStandingsUseCase } from '@core/league/application/use-cases/GetLeagueStandingsUseCase';
import { GetLeagueStandingsUseCaseImpl } from '@core/league/application/use-cases/GetLeagueStandingsUseCaseImpl'; import { GetLeagueStandingsUseCaseImpl } from '@core/league/application/use-cases/GetLeagueStandingsUseCaseImpl';
import { GetLeagueFullConfigUseCase } from '@core/racing/application/use-cases/GetLeagueFullConfigUseCase'; 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 { CreateLeagueWithSeasonAndScoringUseCase } from '@core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase';
import { GetRaceProtestsUseCase } from '@core/racing/application/use-cases/GetRaceProtestsUseCase'; import { GetRaceProtestsUseCase } from '@core/racing/application/use-cases/GetRaceProtestsUseCase';
import { GetTotalLeaguesUseCase } from '@core/racing/application/use-cases/GetTotalLeaguesUseCase'; 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 useFactory: (logger: Logger) => new InMemorySeasonRepository(logger), // Factory for InMemorySeasonRepository
inject: [LOGGER_TOKEN], inject: [LOGGER_TOKEN],
}, },
{
provide: 'ISeasonSponsorshipRepository',
useFactory: (logger: Logger) => new InMemorySeasonSponsorshipRepository(logger),
inject: [LOGGER_TOKEN],
},
{ {
provide: LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN, provide: LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryLeagueScoringConfigRepository(logger), // Factory for InMemoryLeagueScoringConfigRepository useFactory: (logger: Logger) => new InMemoryLeagueScoringConfigRepository(logger), // Factory for InMemoryLeagueScoringConfigRepository
@@ -150,6 +162,23 @@ export const LeagueProviders: Provider[] = [
GetLeagueAdminPermissionsUseCase, GetLeagueAdminPermissionsUseCase,
GetLeagueWalletUseCase, GetLeagueWalletUseCase,
WithdrawFromLeagueWalletUseCase, 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, provide: ListLeagueScoringPresetsUseCase,
useFactory: () => new ListLeagueScoringPresetsUseCase(listLeagueScoringPresets()), useFactory: () => new ListLeagueScoringPresetsUseCase(listLeagueScoringPresets()),

View File

@@ -1,8 +1,12 @@
import { LeagueService } from './LeagueService'; import { LeagueService } from './LeagueService';
import { GetAllLeaguesWithCapacityUseCase } from '@core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase'; 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 { GetLeagueStatsUseCase } from '@core/racing/application/use-cases/GetLeagueStatsUseCase';
import { GetLeagueFullConfigUseCase } from '@core/racing/application/use-cases/GetLeagueFullConfigUseCase'; 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 { CreateLeagueWithSeasonAndScoringUseCase } from '@core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase';
import { GetRaceProtestsUseCase } from '@core/racing/application/use-cases/GetRaceProtestsUseCase'; import { GetRaceProtestsUseCase } from '@core/racing/application/use-cases/GetRaceProtestsUseCase';
import { GetTotalLeaguesUseCase } from '@core/racing/application/use-cases/GetTotalLeaguesUseCase'; 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 { UpdateLeagueMemberRoleUseCase } from '@core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase';
import { GetLeagueOwnerSummaryUseCase } from '@core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase'; import { GetLeagueOwnerSummaryUseCase } from '@core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase';
import { GetLeagueProtestsUseCase } from '@core/racing/application/use-cases/GetLeagueProtestsUseCase'; 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 type { Logger } from '@core/shared/application/Logger';
import { Result } from '@core/shared/application/Result'; import { Result } from '@core/shared/application/Result';
@@ -21,41 +32,58 @@ describe('LeagueService', () => {
let mockGetTotalLeaguesUseCase: jest.Mocked<GetTotalLeaguesUseCase>; let mockGetTotalLeaguesUseCase: jest.Mocked<GetTotalLeaguesUseCase>;
let mockGetLeagueJoinRequestsUseCase: jest.Mocked<GetLeagueJoinRequestsUseCase>; let mockGetLeagueJoinRequestsUseCase: jest.Mocked<GetLeagueJoinRequestsUseCase>;
let mockApproveLeagueJoinRequestUseCase: jest.Mocked<ApproveLeagueJoinRequestUseCase>; let mockApproveLeagueJoinRequestUseCase: jest.Mocked<ApproveLeagueJoinRequestUseCase>;
let mockGetLeagueFullConfigUseCase: jest.Mocked<GetLeagueFullConfigUseCase>;
let mockGetLeagueOwnerSummaryUseCase: jest.Mocked<GetLeagueOwnerSummaryUseCase>;
let mockGetLeagueScheduleUseCase: jest.Mocked<GetLeagueScheduleUseCase>;
let mockGetSeasonSponsorshipsUseCase: jest.Mocked<GetSeasonSponsorshipsUseCase>;
let mockLogger: jest.Mocked<Logger>; let mockLogger: jest.Mocked<Logger>;
beforeEach(() => { beforeEach(() => {
mockGetTotalLeaguesUseCase = { const createUseCaseMock = <T extends { execute: unknown }>(): jest.Mocked<T> => ({
execute: jest.fn(), // eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any; execute: jest.fn() as any,
mockGetLeagueJoinRequestsUseCase = { }) as jest.Mocked<T>;
execute: jest.fn(),
} as any; mockGetTotalLeaguesUseCase = createUseCaseMock<GetTotalLeaguesUseCase>();
mockApproveLeagueJoinRequestUseCase = { mockGetLeagueJoinRequestsUseCase = createUseCaseMock<GetLeagueJoinRequestsUseCase>();
execute: jest.fn(), mockApproveLeagueJoinRequestUseCase = createUseCaseMock<ApproveLeagueJoinRequestUseCase>();
} as any; mockGetLeagueFullConfigUseCase = createUseCaseMock<GetLeagueFullConfigUseCase>();
mockGetLeagueOwnerSummaryUseCase = createUseCaseMock<GetLeagueOwnerSummaryUseCase>();
mockGetLeagueScheduleUseCase = createUseCaseMock<GetLeagueScheduleUseCase>();
mockGetSeasonSponsorshipsUseCase = createUseCaseMock<GetSeasonSponsorshipsUseCase>();
mockLogger = { mockLogger = {
debug: jest.fn(), debug: jest.fn(),
} as any; info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
} as unknown as jest.Mocked<Logger>;
service = new LeagueService( service = new LeagueService(
{} as any, // mockGetAllLeaguesWithCapacityUseCase {} as unknown as GetAllLeaguesWithCapacityUseCase,
{} as any, // mockGetLeagueStandingsUseCase {} as unknown as GetLeagueStandingsUseCase,
{} as any, // mockGetLeagueStatsUseCase {} as unknown as GetLeagueStatsUseCase,
{} as any, // mockGetLeagueFullConfigUseCase mockGetLeagueFullConfigUseCase,
{} as any, // mockCreateLeagueWithSeasonAndScoringUseCase {} as unknown as GetLeagueScoringConfigUseCase,
{} as any, // mockGetRaceProtestsUseCase {} as unknown as ListLeagueScoringPresetsUseCase,
{} as unknown as JoinLeagueUseCase,
{} as unknown as TransferLeagueOwnershipUseCase,
{} as unknown as CreateLeagueWithSeasonAndScoringUseCase,
{} as unknown as GetRaceProtestsUseCase,
mockGetTotalLeaguesUseCase, mockGetTotalLeaguesUseCase,
mockGetLeagueJoinRequestsUseCase, mockGetLeagueJoinRequestsUseCase,
mockApproveLeagueJoinRequestUseCase, mockApproveLeagueJoinRequestUseCase,
{} as any, // mockRejectLeagueJoinRequestUseCase {} as unknown as RejectLeagueJoinRequestUseCase,
{} as any, // mockRemoveLeagueMemberUseCase {} as unknown as RemoveLeagueMemberUseCase,
{} as any, // mockUpdateLeagueMemberRoleUseCase {} as unknown as UpdateLeagueMemberRoleUseCase,
{} as any, // mockGetLeagueOwnerSummaryUseCase mockGetLeagueOwnerSummaryUseCase,
{} as any, // mockGetLeagueProtestsUseCase {} as unknown as GetLeagueProtestsUseCase,
{} as any, // mockGetLeagueSeasonsUseCase {} as unknown as GetLeagueSeasonsUseCase,
{} as any, // mockGetLeagueMembershipsUseCase {} as unknown as GetLeagueMembershipsUseCase,
{} as any, // mockGetLeagueScheduleUseCase mockGetLeagueScheduleUseCase,
{} as any, // mockGetLeagueAdminPermissionsUseCase {} as unknown as GetLeagueAdminPermissionsUseCase,
{} as unknown as GetLeagueWalletUseCase,
{} as unknown as WithdrawFromLeagueWalletUseCase,
mockGetSeasonSponsorshipsUseCase,
mockLogger, mockLogger,
); );
}); });
@@ -70,7 +98,7 @@ describe('LeagueService', () => {
}); });
it('should get league join requests', async () => { it('should get league join requests', async () => {
mockGetLeagueJoinRequestsUseCase.execute.mockImplementation(async (params, presenter) => { mockGetLeagueJoinRequestsUseCase.execute.mockImplementation(async (_params, presenter) => {
presenter.present({ presenter.present({
joinRequests: [{ id: 'req-1', leagueId: 'league-1', driverId: 'driver-1', requestedAt: new Date(), message: 'msg' }], joinRequests: [{ id: 'req-1', leagueId: 'league-1', driverId: 'driver-1', requestedAt: new Date(), message: 'msg' }],
drivers: [{ id: 'driver-1', name: 'Driver 1' }], drivers: [{ id: 'driver-1', name: 'Driver 1' }],
@@ -79,18 +107,20 @@ describe('LeagueService', () => {
const result = await service.getLeagueJoinRequests('league-1'); const result = await service.getLeagueJoinRequests('league-1');
expect(result).toEqual([{ expect(result).toEqual([
id: 'req-1', {
leagueId: 'league-1', id: 'req-1',
driverId: 'driver-1', leagueId: 'league-1',
requestedAt: expect.any(Date), driverId: 'driver-1',
message: 'msg', requestedAt: expect.any(Date),
driver: { id: 'driver-1', name: 'Driver 1' }, message: 'msg',
}]); driver: { id: 'driver-1', name: 'Driver 1' },
},
]);
}); });
it('should approve league join request', async () => { 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.' }); presenter.present({ success: true, message: 'Join request approved.' });
}); });
@@ -100,70 +130,200 @@ describe('LeagueService', () => {
}); });
it('should reject league join request', async () => { it('should reject league join request', async () => {
const mockRejectUseCase = { const mockRejectUseCase: jest.Mocked<RejectLeagueJoinRequestUseCase> = {
execute: jest.fn().mockImplementation(async (params, presenter) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any
presenter.present({ success: true, message: 'Join request rejected.' }); execute: jest.fn() as any,
}), } as unknown as jest.Mocked<RejectLeagueJoinRequestUseCase>;
} as any;
service = new LeagueService( service = new LeagueService(
{} as any, {} as unknown as GetAllLeaguesWithCapacityUseCase,
{} as any, {} as unknown as GetLeagueStandingsUseCase,
{} as any, {} as unknown as GetLeagueStatsUseCase,
{} as any, mockGetLeagueFullConfigUseCase,
{} as any, {} as unknown as GetLeagueScoringConfigUseCase,
{} as any, {} as unknown as ListLeagueScoringPresetsUseCase,
{} as any, {} as unknown as JoinLeagueUseCase,
{} as any, {} as unknown as TransferLeagueOwnershipUseCase,
{} as any, {} as unknown as CreateLeagueWithSeasonAndScoringUseCase,
{} as unknown as GetRaceProtestsUseCase,
mockGetTotalLeaguesUseCase,
mockGetLeagueJoinRequestsUseCase,
mockApproveLeagueJoinRequestUseCase,
mockRejectUseCase, mockRejectUseCase,
{} as any, {} as unknown as RemoveLeagueMemberUseCase,
{} as any, {} as unknown as UpdateLeagueMemberRoleUseCase,
{} as any, mockGetLeagueOwnerSummaryUseCase,
{} as any, {} as unknown as GetLeagueProtestsUseCase,
{} as any, {} as unknown as GetLeagueSeasonsUseCase,
{} as any, {} as unknown as GetLeagueMembershipsUseCase,
{} as any, {} as unknown as GetLeagueScheduleUseCase,
{} as any, {} as unknown as GetLeagueAdminPermissionsUseCase,
{} as unknown as GetLeagueWalletUseCase,
{} as unknown as WithdrawFromLeagueWalletUseCase,
mockLogger, 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' }); const result = await service.rejectLeagueJoinRequest({ requestId: 'req-1', leagueId: 'league-1' });
expect(result).toEqual({ success: true, message: 'Join request rejected.' }); expect(result).toEqual({ success: true, message: 'Join request rejected.' });
}); });
it('should remove league member', async () => { it('should remove league member', async () => {
const mockRemoveUseCase = { const mockRemoveUseCase: jest.Mocked<RemoveLeagueMemberUseCase> = {
execute: jest.fn().mockImplementation(async (params, presenter) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any
presenter.present({ success: true }); execute: jest.fn() as any,
}), } as unknown as jest.Mocked<RemoveLeagueMemberUseCase>;
} as any;
service = new LeagueService( service = new LeagueService(
{} as any, {} as unknown as GetAllLeaguesWithCapacityUseCase,
{} as any, {} as unknown as GetLeagueStandingsUseCase,
{} as any, {} as unknown as GetLeagueStatsUseCase,
{} as any, mockGetLeagueFullConfigUseCase,
{} as any, {} as unknown as GetLeagueScoringConfigUseCase,
{} as any, {} as unknown as ListLeagueScoringPresetsUseCase,
{} as any, {} as unknown as JoinLeagueUseCase,
{} as any, {} as unknown as TransferLeagueOwnershipUseCase,
{} as any, {} as unknown as CreateLeagueWithSeasonAndScoringUseCase,
{} as any, {} as unknown as GetRaceProtestsUseCase,
mockGetTotalLeaguesUseCase,
mockGetLeagueJoinRequestsUseCase,
mockApproveLeagueJoinRequestUseCase,
{} as unknown as RejectLeagueJoinRequestUseCase,
mockRemoveUseCase, mockRemoveUseCase,
{} as any, {} as unknown as UpdateLeagueMemberRoleUseCase,
{} as any, mockGetLeagueOwnerSummaryUseCase,
{} as any, {} as unknown as GetLeagueProtestsUseCase,
{} as any, {} as unknown as GetLeagueSeasonsUseCase,
{} as any, {} as unknown as GetLeagueMembershipsUseCase,
{} as any, {} as unknown as GetLeagueScheduleUseCase,
{} as any, {} as unknown as GetLeagueAdminPermissionsUseCase,
{} as unknown as GetLeagueWalletUseCase,
{} as unknown as WithdrawFromLeagueWalletUseCase,
mockLogger, 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' }); const result = await service.removeLeagueMember({ leagueId: 'league-1', performerDriverId: 'performer-1', targetDriverId: 'driver-1' });
expect(result).toEqual({ success: true }); 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,
});
});
}); });

View File

@@ -60,6 +60,7 @@ import { TransferLeagueOwnershipUseCase } from '@core/racing/application/use-cas
import { UpdateLeagueMemberRoleUseCase } from '@core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase'; import { UpdateLeagueMemberRoleUseCase } from '@core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase';
import { GetLeagueWalletUseCase } from '@core/racing/application/use-cases/GetLeagueWalletUseCase'; import { GetLeagueWalletUseCase } from '@core/racing/application/use-cases/GetLeagueWalletUseCase';
import { WithdrawFromLeagueWalletUseCase } from '@core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase'; import { WithdrawFromLeagueWalletUseCase } from '@core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase';
import { GetSeasonSponsorshipsUseCase } from '@core/racing/application/use-cases/GetSeasonSponsorshipsUseCase';
// API Presenters // API Presenters
import { AllLeaguesWithCapacityPresenter } from './presenters/AllLeaguesWithCapacityPresenter'; import { AllLeaguesWithCapacityPresenter } from './presenters/AllLeaguesWithCapacityPresenter';
@@ -70,7 +71,7 @@ import { mapApproveLeagueJoinRequestPortToDTO } from './presenters/ApproveLeague
import { GetLeagueAdminPermissionsPresenter } from './presenters/GetLeagueAdminPermissionsPresenter'; import { GetLeagueAdminPermissionsPresenter } from './presenters/GetLeagueAdminPermissionsPresenter';
import { mapGetLeagueOwnerSummaryOutputPortToDTO } from './presenters/GetLeagueOwnerSummaryPresenter'; import { mapGetLeagueOwnerSummaryOutputPortToDTO } from './presenters/GetLeagueOwnerSummaryPresenter';
import { LeagueJoinRequestsPresenter } from './presenters/LeagueJoinRequestsPresenter'; import { LeagueJoinRequestsPresenter } from './presenters/LeagueJoinRequestsPresenter';
import { mapGetLeagueScheduleOutputPortToDTO } from './presenters/LeagueSchedulePresenter'; import { mapGetLeagueScheduleOutputPortToDTO, mapGetLeagueScheduleOutputPortToRaceDTOs } from './presenters/LeagueSchedulePresenter';
import { LeagueStatsPresenter } from './presenters/LeagueStatsPresenter'; import { LeagueStatsPresenter } from './presenters/LeagueStatsPresenter';
import { mapRejectLeagueJoinRequestOutputPortToDTO } from './presenters/RejectLeagueJoinRequestPresenter'; import { mapRejectLeagueJoinRequestOutputPortToDTO } from './presenters/RejectLeagueJoinRequestPresenter';
import { mapRemoveLeagueMemberOutputPortToDTO } from './presenters/RemoveLeagueMemberPresenter'; import { mapRemoveLeagueMemberOutputPortToDTO } from './presenters/RemoveLeagueMemberPresenter';
@@ -112,6 +113,7 @@ export class LeagueService {
private readonly getLeagueAdminPermissionsUseCase: GetLeagueAdminPermissionsUseCase, private readonly getLeagueAdminPermissionsUseCase: GetLeagueAdminPermissionsUseCase,
private readonly getLeagueWalletUseCase: GetLeagueWalletUseCase, private readonly getLeagueWalletUseCase: GetLeagueWalletUseCase,
private readonly withdrawFromLeagueWalletUseCase: WithdrawFromLeagueWalletUseCase, private readonly withdrawFromLeagueWalletUseCase: WithdrawFromLeagueWalletUseCase,
private readonly getSeasonSponsorshipsUseCase: GetSeasonSponsorshipsUseCase,
@Inject(LOGGER_TOKEN) private readonly logger: Logger, @Inject(LOGGER_TOKEN) private readonly logger: Logger,
) {} ) {}
@@ -263,11 +265,21 @@ export class LeagueService {
async getLeagueSchedule(leagueId: string): Promise<LeagueScheduleDTO> { async getLeagueSchedule(leagueId: string): Promise<LeagueScheduleDTO> {
this.logger.debug('Getting league schedule', { leagueId }); this.logger.debug('Getting league schedule', { leagueId });
const result = await this.getLeagueScheduleUseCase.execute({ leagueId });
if (result.isErr()) { const [scheduleResult, leagueConfigResult] = await Promise.all([
throw new Error(result.unwrapErr().code); 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<LeagueStatsDTO> { async getLeagueStats(leagueId: string): Promise<LeagueStatsDTO> {
@@ -281,64 +293,49 @@ export class LeagueService {
return presenter.getViewModel()!; return presenter.getViewModel()!;
} }
async getLeagueAdmin(leagueId: string): Promise<LeagueAdminDTO> { private async getLeagueAdminComposite(leagueId: string): Promise<LeagueAdminDTO> {
this.logger.debug('Getting league admin data', { leagueId }); this.logger.debug('Fetching composite 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 const [fullConfigResult, joinRequests, protests, seasons] = await Promise.all([
// In a full implementation, we'd have a use case that gets league basic info this.getLeagueFullConfigUseCase.execute({ leagueId }),
const ownerSummary = config ? await this.getLeagueOwnerSummary({ ownerId: 'placeholder', leagueId }) : null; this.getLeagueJoinRequests(leagueId),
this.getLeagueProtests({ leagueId }),
this.getLeagueSeasons({ leagueId }),
]);
// Convert config from view model to DTO format manually with proper types if (fullConfigResult.isErr()) {
const configForm = config ? { throw new Error(fullConfigResult.unwrapErr().code);
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;
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, joinRequests: joinRequests.joinRequests,
ownerSummary: ownerSummary?.summary || null, ownerSummary,
config: { form: configForm }, config: configForm,
protests, protests,
seasons, seasons,
}; });
return adminPresenter.getViewModel();
}
async getLeagueAdmin(leagueId: string): Promise<LeagueAdminDTO> {
this.logger.debug('Getting league admin data', { leagueId });
return this.getLeagueAdminComposite(leagueId);
} }
async createLeague(input: CreateLeagueInputDTO): Promise<CreateLeagueViewModel> { async createLeague(input: CreateLeagueInputDTO): Promise<CreateLeagueViewModel> {
@@ -426,20 +423,30 @@ export class LeagueService {
async getSeasonSponsorships(seasonId: string): Promise<GetSeasonSponsorshipsOutputDTO> { async getSeasonSponsorships(seasonId: string): Promise<GetSeasonSponsorshipsOutputDTO> {
this.logger.debug('Getting season sponsorships', { seasonId }); this.logger.debug('Getting season sponsorships', { seasonId });
// TODO: Implement actual logic to fetch season sponsorships const result = await this.getSeasonSponsorshipsUseCase.execute({ seasonId });
// For now, return empty array as placeholder if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
const value = result.unwrap();
return { return {
sponsorships: [], sponsorships: value?.sponsorships ?? [],
}; };
} }
async getRaces(leagueId: string): Promise<GetLeagueRacesOutputDTO> { async getRaces(leagueId: string): Promise<GetLeagueRacesOutputDTO> {
this.logger.debug('Getting league races', { leagueId }); this.logger.debug('Getting league races', { leagueId });
// TODO: Implement actual logic to fetch league races const result = await this.getLeagueScheduleUseCase.execute({ leagueId });
// For now, return empty array as placeholder if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
const races = mapGetLeagueScheduleOutputPortToRaceDTOs(result.unwrap());
return { return {
races: [], races,
}; };
} }

View File

@@ -2,13 +2,23 @@ import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsDate, IsEnum } from 'class-validator'; import { IsString, IsDate, IsEnum } from 'class-validator';
import { Type } from 'class-transformer'; 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 { export class ProtestDTO {
@ApiProperty() @ApiProperty()
@IsString() @IsString()
id: string; id: string;
@ApiProperty()
@IsString()
leagueId: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
raceId: string; raceId: string;

View File

@@ -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 { LeagueAdminProtestsDTO } from '../dtos/LeagueAdminProtestsDTO';
import { ProtestDTO } from '../dtos/ProtestDTO'; import { ProtestDTO } from '../dtos/ProtestDTO';
import { RaceDTO } from '../../race/dtos/RaceDTO'; import { RaceDTO } from '../../race/dtos/RaceDTO';
import { DriverDTO } from '../../driver/dtos/DriverDTO'; import { DriverDTO } from '../../driver/dtos/DriverDTO';
export function mapGetLeagueProtestsOutputPortToDTO(output: GetLeagueProtestsOutputPort): LeagueAdminProtestsDTO { function mapProtestStatus(status: ProtestOutputPort['status']): ProtestDTO['status'] {
const protests: ProtestDTO[] = output.protests.map(protest => ({ switch (status) {
id: protest.id, case 'pending':
raceId: protest.raceId, case 'awaiting_defense':
protestingDriverId: protest.protestingDriverId, case 'under_review':
accusedDriverId: protest.accusedDriverId, return 'pending';
submittedAt: new Date(protest.filedAt), case 'upheld':
description: protest.incident.description, return 'accepted';
status: protest.status as 'pending' | 'accepted' | 'rejected', // TODO: map properly 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 } = {}; const racesById: { [raceId: string]: RaceDTO } = {};
for (const raceId in output.racesById) { for (const raceId in output.racesById) {
const race = output.racesById[raceId]; const race = output.racesById[raceId];
racesById[raceId] = { racesById[raceId] = {
id: race.id, id: race.id,
name: race.track, // assuming name is track name: race.track,
date: race.scheduledAt, date: race.scheduledAt,
leagueName: undefined, // TODO: get league name if needed leagueName,
}; };
} }

View File

@@ -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> = {}): 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');
});
});

View File

@@ -10,45 +10,64 @@ export class LeagueConfigPresenter implements Presenter<LeagueFullConfigOutputPo
} }
present(dto: LeagueFullConfigOutputPort) { present(dto: LeagueFullConfigOutputPort) {
// Map from LeagueFullConfigOutputPort to LeagueConfigFormModelDTO
const league = dto.league; const league = dto.league;
const settings = league.settings; const settings = league.settings;
const stewarding = settings.stewarding; const stewarding = dto.activeSeason?.stewardingConfig;
const dropPolicy = dto.activeSeason?.dropPolicy;
const schedule = dto.activeSeason?.schedule;
const scoringConfig = dto.scoringConfig;
const visibility: 'public' | 'private' = 'public';
const championships = scoringConfig?.championships ?? [];
const firstChampionship = championships[0];
const firstSessionType = firstChampionship?.sessionTypes[0];
const firstPointsTable = firstSessionType
? firstChampionship.pointsTableBySessionType[firstSessionType]
: undefined;
const pointsForWin = firstPointsTable?.getPointsForPosition(1) ?? 0;
const raceDayOfWeek = schedule?.startDate
? schedule.startDate.toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase()
: 'sunday';
const raceTimeHour = schedule?.timeOfDay?.hour ?? 20;
const raceTimeMinute = schedule?.timeOfDay?.minute ?? 0;
this.result = { this.result = {
leagueId: league.id, leagueId: league.id,
basics: { basics: {
name: league.name, name: league.name,
description: league.description, description: league.description,
visibility: 'public', // TODO: Map visibility from league visibility,
}, },
structure: { structure: {
mode: 'solo', // TODO: Map from league settings mode: 'solo',
}, },
championships: [], // TODO: Map championships championships,
scoring: { scoring: {
type: 'standard', // TODO: Map scoring type type: settings.pointsSystem,
points: 25, // TODO: Map points points: pointsForWin,
}, },
dropPolicy: { dropPolicy: {
strategy: 'none', // TODO: Map strategy: dropPolicy?.strategy === 'none' ? 'none' : 'worst_n',
n: 0, n: dropPolicy?.n,
}, },
timings: { timings: {
raceDayOfWeek: 'sunday', // TODO: Map from timings raceDayOfWeek,
raceTimeHour: 20, raceTimeHour,
raceTimeMinute: 0, raceTimeMinute,
}, },
stewarding: { stewarding: {
decisionMode: stewarding?.decisionMode === 'steward_vote' ? 'committee_vote' : 'single_steward', decisionMode: stewarding?.decisionMode === 'steward_vote' ? 'committee_vote' : 'single_steward',
requireDefense: stewarding?.requireDefense || false, requireDefense: stewarding?.requireDefense ?? false,
defenseTimeLimit: stewarding?.defenseTimeLimit || 48, defenseTimeLimit: stewarding?.defenseTimeLimit ?? 48,
voteTimeLimit: stewarding?.voteTimeLimit || 72, voteTimeLimit: stewarding?.voteTimeLimit ?? 72,
protestDeadlineHours: stewarding?.protestDeadlineHours || 48, protestDeadlineHours: stewarding?.protestDeadlineHours ?? 48,
stewardingClosesHours: stewarding?.stewardingClosesHours || 168, stewardingClosesHours: stewarding?.stewardingClosesHours ?? 168,
notifyAccusedOnProtest: stewarding?.notifyAccusedOnProtest || true, notifyAccusedOnProtest: stewarding?.notifyAccusedOnProtest ?? true,
notifyOnVoteRequired: stewarding?.notifyOnVoteRequired || true, notifyOnVoteRequired: stewarding?.notifyOnVoteRequired ?? true,
requiredVotes: stewarding?.requiredVotes || 0, requiredVotes: stewarding?.requiredVotes,
}, },
}; };
} }

View File

@@ -2,13 +2,22 @@ import { GetLeagueScheduleOutputPort } from '@core/racing/application/ports/outp
import { LeagueScheduleDTO } from '../dtos/LeagueScheduleDTO'; import { LeagueScheduleDTO } from '../dtos/LeagueScheduleDTO';
import { RaceDTO } from '../../race/dtos/RaceDTO'; import { RaceDTO } from '../../race/dtos/RaceDTO';
export function mapGetLeagueScheduleOutputPortToDTO(output: GetLeagueScheduleOutputPort): LeagueScheduleDTO { export function mapGetLeagueScheduleOutputPortToDTO(output: GetLeagueScheduleOutputPort, leagueName?: string): LeagueScheduleDTO {
return { return {
races: output.races.map(race => ({ races: output.races.map<RaceDTO>(race => ({
id: race.id, id: race.id,
name: race.name, name: race.name,
date: race.scheduledAt.toISOString(), 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<RaceDTO>(race => ({
id: race.id,
name: race.name,
date: race.scheduledAt.toISOString(),
leagueName,
}));
} }

View File

@@ -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);
});
})

View File

@@ -9,6 +9,15 @@ export class GetAllRacesPresenter {
} }
async present(output: GetAllRacesOutputPort) { async present(output: GetAllRacesOutputPort) {
const uniqueLeagues = new Map<string, { id: string; name: string }>();
for (const race of output.races) {
uniqueLeagues.set(race.leagueId, {
id: race.leagueId,
name: race.leagueName,
});
}
this.result = { this.result = {
races: output.races.map(race => ({ races: output.races.map(race => ({
id: race.id, id: race.id,
@@ -28,7 +37,7 @@ export class GetAllRacesPresenter {
{ value: 'completed', label: 'Completed' }, { value: 'completed', label: 'Completed' },
{ value: 'cancelled', label: 'Cancelled' }, { value: 'cancelled', label: 'Cancelled' },
], ],
leagues: [], // TODO: populate if needed leagues: Array.from(uniqueLeagues.values()),
}, },
}; };
} }

View File

@@ -1,7 +1,5 @@
'use client'; 'use client';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import type { Notification } from '@core/notifications/application';
import { import {
AlertTriangle, AlertTriangle,
Bell, Bell,
@@ -36,33 +34,14 @@ const notificationColors: Record<string, string> = {
race_reminder: 'text-warning-amber bg-warning-amber/10', race_reminder: 'text-warning-amber bg-warning-amber/10',
}; };
import { useNotifications } from './NotificationProvider';
import type { Notification } from './NotificationProvider';
export default function NotificationCenter() { export default function NotificationCenter() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loading, setLoading] = useState(false);
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const router = useRouter(); const router = useRouter();
const currentDriverId = useEffectiveDriverId(); const { notifications, unreadCount, markAsRead, markAllAsRead } = useNotifications();
// 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]);
// Close panel when clicking outside // Close panel when clicking outside
useEffect(() => { useEffect(() => {
@@ -81,42 +60,9 @@ export default function NotificationCenter() {
}; };
}, [isOpen]); }, [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) { if (notification.actionUrl) {
router.push(notification.actionUrl); router.push(notification.actionUrl);
setIsOpen(false); setIsOpen(false);
@@ -176,7 +122,7 @@ export default function NotificationCenter() {
</div> </div>
{unreadCount > 0 && ( {unreadCount > 0 && (
<button <button
onClick={handleMarkAllAsRead} onClick={markAllAsRead}
className="flex items-center gap-1 text-xs text-gray-400 hover:text-white transition-colors" className="flex items-center gap-1 text-xs text-gray-400 hover:text-white transition-colors"
> >
<CheckCheck className="w-3.5 h-3.5" /> <CheckCheck className="w-3.5 h-3.5" />
@@ -209,7 +155,7 @@ export default function NotificationCenter() {
onClick={() => handleNotificationClick(notification)} onClick={() => handleNotificationClick(notification)}
className={` className={`
w-full text-left px-4 py-3 transition-colors hover:bg-iron-gray/30 w-full text-left px-4 py-3 transition-colors hover:bg-iron-gray/30
${notification.isUnread() ? 'bg-primary-blue/5' : ''} ${!notification.read ? 'bg-primary-blue/5' : ''}
`} `}
> >
<div className="flex gap-3"> <div className="flex gap-3">
@@ -219,16 +165,16 @@ export default function NotificationCenter() {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<p className={`text-sm font-medium truncate ${ <p className={`text-sm font-medium truncate ${
notification.isUnread() ? 'text-white' : 'text-gray-300' !notification.read ? 'text-white' : 'text-gray-300'
}`}> }`}>
{notification.title} {notification.title}
</p> </p>
{notification.isUnread() && ( {!notification.read && (
<span className="w-2 h-2 bg-primary-blue rounded-full flex-shrink-0 mt-1.5" /> <span className="w-2 h-2 bg-primary-blue rounded-full flex-shrink-0 mt-1.5" />
)} )}
</div> </div>
<p className="text-xs text-gray-500 line-clamp-2 mt-0.5"> <p className="text-xs text-gray-500 line-clamp-2 mt-0.5">
{notification.body} {notification.message}
</p> </p>
<div className="flex items-center gap-2 mt-1.5"> <div className="flex items-center gap-2 mt-1.5">
<span className="text-[10px] text-gray-600"> <span className="text-[10px] text-gray-600">

View File

@@ -1,21 +1,55 @@
'use client'; 'use client';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react'; import { v4 as uuid } from 'uuid';
import type { Notification } from '@core/notifications/application';
import ModalNotification from './ModalNotification'; import ModalNotification from './ModalNotification';
import ToastNotification from './ToastNotification'; import ToastNotification from './ToastNotification';
export type NotificationVariant = 'toast' | 'modal' | 'center';
export interface NotificationAction {
id: string;
label: string;
type?: 'primary' | 'secondary' | 'danger';
href?: string;
}
export interface Notification {
id: string;
type: string;
title?: string;
message: string;
createdAt: Date;
variant: NotificationVariant;
actionUrl?: string;
requiresResponse?: boolean;
actions?: NotificationAction[];
data?: Record<string, unknown>;
read: boolean;
}
interface AddNotificationInput {
id?: string;
type: string;
title?: string;
message: string;
createdAt?: Date;
variant?: NotificationVariant;
actionUrl?: string;
requiresResponse?: boolean;
actions?: NotificationAction[];
data?: Record<string, unknown>;
}
interface NotificationContextValue { interface NotificationContextValue {
notifications: Notification[]; notifications: Notification[];
unreadCount: number; unreadCount: number;
toastNotifications: Notification[]; addNotification: (input: AddNotificationInput) => string;
modalNotification: Notification | null; dismissNotification: (id: string) => void;
markAsRead: (notification: Notification) => Promise<void>; clearNotifications: () => void;
dismissToast: (notification: Notification) => void; markAsRead: (id: string) => void;
respondToModal: (notification: Notification, actionId?: string) => Promise<void>; markAllAsRead: () => void;
dismissModal: (notification: Notification) => Promise<void>;
} }
const NotificationContext = createContext<NotificationContextValue | null>(null); const NotificationContext = createContext<NotificationContextValue | null>(null);
@@ -34,133 +68,85 @@ interface NotificationProviderProps {
export default function NotificationProvider({ children }: NotificationProviderProps) { export default function NotificationProvider({ children }: NotificationProviderProps) {
const [notifications, setNotifications] = useState<Notification[]>([]); const [notifications, setNotifications] = useState<Notification[]>([]);
const [toastNotifications, setToastNotifications] = useState<Notification[]>([]);
const [modalNotification, setModalNotification] = useState<Notification | null>(null);
const [seenNotificationIds, setSeenNotificationIds] = useState<Set<string>>(new Set());
const currentDriverId = useEffectiveDriverId();
// Poll for new notifications const addNotification = useCallback((input: AddNotificationInput): string => {
// TODO const id = input.id ?? uuid();
// useEffect(() => {
// const loadNotifications = async () => {
// try {
// const repo = getNotificationRepository();
// const allNotifications = await repo.findByRecipientId(currentDriverId);
// setNotifications(allNotifications);
// // Check for new notifications that need toast/modal display const notification: Notification = {
// allNotifications.forEach((notification) => { id,
// // Check both unread and action_required status for modals type: input.type,
// const shouldDisplay = (notification.isUnread() || notification.isActionRequired()) && title: input.title,
// !seenNotificationIds.has(notification.id); message: input.message,
createdAt: input.createdAt ?? new Date(),
// if (shouldDisplay) { variant: input.variant ?? 'toast',
// // Mark as seen to prevent duplicate displays actionUrl: input.actionUrl,
// setSeenNotificationIds((prev) => new Set([...prev, notification.id])); requiresResponse: input.requiresResponse,
actions: input.actions,
data: input.data,
read: false,
};
// // Handle based on urgency setNotifications((prev) => [notification, ...prev]);
// if (notification.isModal()) {
// // Modal takes priority - show immediately
// setModalNotification(notification);
// } else if (notification.isToast()) {
// // Add to toast queue
// setToastNotifications((prev) => [...prev, notification]);
// }
// // Silent notifications just appear in the notification center
// }
// });
// } catch (error) {
// console.error('Failed to load notifications:', error);
// }
// };
// loadNotifications(); return id;
}, []);
// // Poll every 2 seconds for responsiveness
// const interval = setInterval(loadNotifications, 2000); const dismissNotification = useCallback((id: string) => {
// return () => clearInterval(interval); setNotifications((prev) => prev.filter((notification) => notification.id !== id));
// }, [currentDriverId, seenNotificationIds]); }, []);
const clearNotifications = useCallback(() => {
setNotifications([]);
}, []);
const markAsRead = useCallback((id: string) => {
setNotifications((prev) =>
prev.map((notification) =>
notification.id === id ? { ...notification, read: true } : notification,
),
);
}, []);
const markAllAsRead = useCallback(() => {
setNotifications((prev) => prev.map((notification) => ({ ...notification, read: true })));
}, []);
const unreadCount = useMemo(
() => notifications.filter((notification) => !notification.read).length,
[notifications],
);
const modalNotification = useMemo(
() => notifications.find((notification) => notification.variant === 'modal' && !notification.read) ?? null,
[notifications],
);
const toastNotifications = useMemo(
() => notifications.filter((notification) => notification.variant === 'toast' && !notification.read),
[notifications],
);
// Prevent body scroll when modal is open
useEffect(() => { useEffect(() => {
if (modalNotification) { if (!modalNotification) {
document.body.style.overflow = 'hidden'; return;
return () => {
document.body.style.overflow = '';
};
} }
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [modalNotification]); }, [modalNotification]);
const markAsRead = useCallback(async (notification: Notification) => {
try {
const markRead = getMarkNotificationReadUseCase();
await markRead.execute({
notificationId: notification.id,
recipientId: currentDriverId,
});
setNotifications((prev) =>
prev.map((n) => (n.id === notification.id ? n.markAsRead() : n))
);
} catch (error) {
console.error('Failed to mark notification as read:', error);
}
}, [currentDriverId]);
const dismissToast = useCallback((notification: Notification) => {
setToastNotifications((prev) => prev.filter((n) => n.id !== notification.id));
}, []);
const respondToModal = useCallback(async (notification: Notification, actionId?: string) => {
try {
// Mark as responded
const repo = getNotificationRepository();
const updated = notification.markAsResponded(actionId);
await repo.update(updated);
// Update local state
setNotifications((prev) =>
prev.map((n) => (n.id === notification.id ? updated : n))
);
// Clear modal
setModalNotification(null);
} catch (error) {
console.error('Failed to respond to notification:', error);
}
}, []);
const dismissModal = useCallback(async (notification: Notification) => {
try {
// Dismiss the notification
const repo = getNotificationRepository();
const updated = notification.dismiss();
await repo.update(updated);
// Update local state
setNotifications((prev) =>
prev.map((n) => (n.id === notification.id ? updated : n))
);
// Clear modal
setModalNotification(null);
} catch (error) {
console.error('Failed to dismiss notification:', error);
}
}, []);
const unreadCount = notifications.filter((n) => n.isUnread() || n.isActionRequired()).length;
const value: NotificationContextValue = { const value: NotificationContextValue = {
notifications, notifications,
unreadCount, unreadCount,
toastNotifications, addNotification,
modalNotification, dismissNotification,
clearNotifications,
markAsRead, markAsRead,
dismissToast, markAllAsRead,
respondToModal,
dismissModal,
}; };
return ( return (
@@ -173,8 +159,8 @@ export default function NotificationProvider({ children }: NotificationProviderP
<ToastNotification <ToastNotification
key={notification.id} key={notification.id}
notification={notification} notification={notification}
onDismiss={dismissToast} onDismiss={() => dismissNotification(notification.id)}
onRead={markAsRead} onRead={() => markAsRead(notification.id)}
/> />
))} ))}
</div> </div>
@@ -183,8 +169,17 @@ export default function NotificationProvider({ children }: NotificationProviderP
{modalNotification && ( {modalNotification && (
<ModalNotification <ModalNotification
notification={modalNotification} notification={modalNotification}
onAction={respondToModal} onAction={(notification, actionId) => {
onDismiss={dismissModal} // For now we just mark as read and optionally navigate via ModalNotification
markAsRead(notification.id);
if (actionId === 'dismiss') {
dismissNotification(notification.id);
}
}}
onDismiss={(notification) => {
markAsRead(notification.id);
dismissNotification(notification.id);
}}
/> />
)} )}
</NotificationContext.Provider> </NotificationContext.Provider>

View File

@@ -9,6 +9,8 @@ export interface ProtestDecisionData {
stewardNotes: string; stewardNotes: string;
} }
const DEFAULT_PROTEST_REASON = 'Protest upheld';
export class ProtestDecisionCommandModel { export class ProtestDecisionCommandModel {
decision: 'uphold' | 'dismiss' | null = null; decision: 'uphold' | 'dismiss' | null = null;
penaltyType: PenaltyType = 'time_penalty'; penaltyType: PenaltyType = 'time_penalty';
@@ -38,13 +40,17 @@ export class ProtestDecisionCommandModel {
} }
toApplyPenaltyCommand(raceId: string, driverId: string, stewardId: string, protestId: string): ApplyPenaltyCommandDTO { toApplyPenaltyCommand(raceId: string, driverId: string, stewardId: string, protestId: string): ApplyPenaltyCommandDTO {
const reason = this.decision === 'uphold'
? DEFAULT_PROTEST_REASON
: 'Protest dismissed';
return { return {
raceId, raceId,
driverId, driverId,
stewardId, stewardId,
type: this.penaltyType, type: this.penaltyType,
value: this.getPenaltyValue(), value: this.getPenaltyValue(),
reason: 'Protest upheld', // TODO: Make this configurable reason,
protestId, protestId,
notes: this.stewardNotes, notes: this.stewardNotes,
}; };

View File

@@ -1,9 +1,7 @@
import { AuthApiClient } from '../../api/auth/AuthApiClient'; import { AuthApiClient } from '../../api/auth/AuthApiClient';
import { SessionViewModel } from '../../view-models/SessionViewModel'; import { SessionViewModel } from '../../view-models/SessionViewModel';
import type { LoginParams } from '../../types/generated/LoginParams';
// TODO: Create DTOs for login/signup params in apps/website/lib/types/generated import type { SignupParams } from '../../types/generated/SignupParams';
type LoginParamsDto = { email: string; password: string };
type SignupParamsDto = { email: string; password: string; displayName: string };
/** /**
* Auth Service * Auth Service
@@ -19,7 +17,7 @@ export class AuthService {
/** /**
* Sign up a new user * Sign up a new user
*/ */
async signup(params: SignupParamsDto): Promise<SessionViewModel> { async signup(params: SignupParams): Promise<SessionViewModel> {
try { try {
const dto = await this.apiClient.signup(params); const dto = await this.apiClient.signup(params);
return new SessionViewModel(dto.user); return new SessionViewModel(dto.user);
@@ -31,7 +29,7 @@ export class AuthService {
/** /**
* Log in an existing user * Log in an existing user
*/ */
async login(params: LoginParamsDto): Promise<SessionViewModel> { async login(params: LoginParams): Promise<SessionViewModel> {
try { try {
const dto = await this.apiClient.login(params); const dto = await this.apiClient.login(params);
return new SessionViewModel(dto.user); return new SessionViewModel(dto.user);
@@ -57,4 +55,4 @@ export class AuthService {
getIracingAuthUrl(returnTo?: string): string { getIracingAuthUrl(returnTo?: string): string {
return this.apiClient.getIracingAuthUrl(returnTo); return this.apiClient.getIracingAuthUrl(returnTo);
} }
} }

View File

@@ -1,20 +1,12 @@
import { DriversApiClient } from "@/lib/api/drivers/DriversApiClient"; import { DriversApiClient } from "@/lib/api/drivers/DriversApiClient";
import { CompleteOnboardingInputDTO } from "@/lib/types/generated/CompleteOnboardingInputDTO"; import { CompleteOnboardingInputDTO } from "@/lib/types/generated/CompleteOnboardingInputDTO";
import { DriverProfileDTO } from "@/lib/types/generated/DriverProfileDTO"; import { DriverProfileDTO } from "@/lib/types/generated/DriverProfileDTO";
import type { DriverDTO } from "@/lib/types/generated/DriverDTO";
import { CompleteOnboardingViewModel } from "@/lib/view-models/CompleteOnboardingViewModel"; import { CompleteOnboardingViewModel } from "@/lib/view-models/CompleteOnboardingViewModel";
import { DriverLeaderboardViewModel } from "@/lib/view-models/DriverLeaderboardViewModel"; import { DriverLeaderboardViewModel } from "@/lib/view-models/DriverLeaderboardViewModel";
import { DriverViewModel } from "@/lib/view-models/DriverViewModel"; import { DriverViewModel } from "@/lib/view-models/DriverViewModel";
import { DriverProfileViewModel } from "@/lib/view-models/DriverProfileViewModel"; import { DriverProfileViewModel } from "@/lib/view-models/DriverProfileViewModel";
// TODO: Create proper DriverDTO in generated types
type DriverDTO = {
id: string;
name: string;
avatarUrl?: string;
iracingId?: string;
rating?: number;
};
/** /**
* Driver Service * Driver Service
* *

View File

@@ -50,13 +50,23 @@ export class LeagueService {
* Get league standings with view model transformation * Get league standings with view model transformation
*/ */
async getLeagueStandings(leagueId: string, currentUserId: string): Promise<LeagueStandingsViewModel> { async getLeagueStandings(leagueId: string, currentUserId: string): Promise<LeagueStandingsViewModel> {
// Core standings (positions, points, driverIds)
const dto = await this.apiClient.getStandings(leagueId); const dto = await this.apiClient.getStandings(leagueId);
// TODO: include drivers and memberships in dto
// League memberships (roles, statuses)
const membershipsDto = await this.apiClient.getMemberships(leagueId);
// Resolve unique drivers that appear in standings
const driverIds = Array.from(new Set(dto.standings.map(entry => entry.driverId)));
const driverDtos = await Promise.all(driverIds.map(id => this.driversApiClient.getDriver(id)));
const drivers = driverDtos.filter((d): d is NonNullable<typeof d> => d !== null);
const dtoWithExtras = { const dtoWithExtras = {
...dto, standings: dto.standings,
drivers: [], // TODO: fetch drivers drivers,
memberships: [], // TODO: fetch memberships memberships: membershipsDto.members,
}; };
return new LeagueStandingsViewModel(dtoWithExtras, currentUserId); return new LeagueStandingsViewModel(dtoWithExtras, currentUserId);
} }
@@ -125,12 +135,12 @@ export class LeagueService {
const leagueDto = allLeagues.leagues.find(l => l.id === leagueId); const leagueDto = allLeagues.leagues.find(l => l.id === leagueId);
if (!leagueDto) return null; if (!leagueDto) return null;
// Assume league has description, ownerId - need to update DTO // LeagueWithCapacityDTO already carries core fields; fall back to placeholder description/owner when not provided
const league = { const league = {
id: leagueDto.id, id: leagueDto.id,
name: leagueDto.name, name: leagueDto.name,
description: 'Description not available', // TODO: add to API description: (leagueDto as any).description ?? 'Description not available',
ownerId: 'owner-id', // TODO: add to API ownerId: (leagueDto as any).ownerId ?? 'owner-id',
}; };
// Get owner // Get owner
@@ -189,20 +199,21 @@ export class LeagueService {
// Get owner // Get owner
const owner = await this.driversApiClient.getDriver(league.ownerId); const owner = await this.driversApiClient.getDriver(league.ownerId);
// Get scoring config - TODO: implement API endpoint // League scoring configuration is not exposed separately yet; use null to indicate "not configured" in the UI
const scoringConfig: LeagueScoringConfigDTO | null = null; // TODO: fetch from API const scoringConfig: LeagueScoringConfigDTO | null = null;
// Get all drivers - TODO: implement API endpoint for all drivers // Drivers list is limited to those present in memberships until a dedicated league-drivers endpoint exists
const drivers: DriverDTO[] = []; // TODO: fetch from API
// Get memberships
const memberships = await this.apiClient.getMemberships(leagueId); const memberships = await this.apiClient.getMemberships(leagueId);
const driverIds = memberships.members.map(m => m.driverId);
const driverDtos = await Promise.all(driverIds.map(id => this.driversApiClient.getDriver(id)));
const drivers = driverDtos.filter((d): d is NonNullable<typeof d> => d !== null);
// Get all races for this league - TODO: implement API endpoint // Get all races for this league via the leagues API helper
const allRaces: RaceViewModel[] = []; // TODO: fetch from API and map to RaceViewModel const leagueRaces = await this.apiClient.getRaces(leagueId);
const allRaces = leagueRaces.races.map(r => new RaceViewModel(r as RaceDTO));
// Get league stats // League stats endpoint currently returns global league statistics rather than per-league values
const leagueStats = await this.apiClient.getTotal(); // TODO: get stats for specific league const leagueStats = await this.apiClient.getTotal();
// Get sponsors // Get sponsors
const sponsors = await this.getLeagueSponsors(leagueId); const sponsors = await this.getLeagueSponsors(leagueId);
@@ -240,14 +251,14 @@ export class LeagueService {
for (const sponsorship of activeSponsorships) { for (const sponsorship of activeSponsorships) {
const sponsor = await this.sponsorsApiClient.getSponsor(sponsorship.sponsorId); const sponsor = await this.sponsorsApiClient.getSponsor(sponsorship.sponsorId);
if (sponsor) { if (sponsor) {
// TODO: Get tagline from testing support or API // Tagline is not supplied by the sponsor API in this build; callers may derive one from marketing content if needed
sponsorInfos.push({ sponsorInfos.push({
id: sponsor.id, id: sponsor.id,
name: sponsor.name, name: sponsor.name,
logoUrl: sponsor.logoUrl ?? '', logoUrl: sponsor.logoUrl ?? '',
websiteUrl: sponsor.websiteUrl ?? '', websiteUrl: sponsor.websiteUrl ?? '',
tier: sponsorship.tier, tier: sponsorship.tier,
tagline: '', // TODO: fetch tagline tagline: '',
}); });
} }
} }

View File

@@ -23,16 +23,15 @@ export class LeagueSettingsService {
*/ */
async getLeagueSettings(leagueId: string): Promise<LeagueSettingsViewModel | null> { async getLeagueSettings(leagueId: string): Promise<LeagueSettingsViewModel | null> {
try { try {
// Get league basic info // Get league basic info (includes ownerId in DTO)
const allLeagues = await this.leaguesApiClient.getAllWithCapacity(); const allLeagues = await this.leaguesApiClient.getAllWithCapacity();
const leagueDto = allLeagues.leagues.find(l => l.id === leagueId); const leagueDto = allLeagues.leagues.find(l => l.id === leagueId);
if (!leagueDto) return null; if (!leagueDto) return null;
// Assume league has ownerId - need to update API
const league = { const league = {
id: leagueDto.id, id: leagueDto.id,
name: leagueDto.name, name: leagueDto.name,
ownerId: 'owner-id', // TODO: add to API ownerId: leagueDto.ownerId,
}; };
// Get config // Get config
@@ -43,15 +42,21 @@ export class LeagueSettingsService {
const presetsDto = await this.leaguesApiClient.getScoringPresets(); const presetsDto = await this.leaguesApiClient.getScoringPresets();
const presets: LeagueScoringPresetDTO[] = presetsDto.presets; const presets: LeagueScoringPresetDTO[] = presetsDto.presets;
// Get leaderboard once so we can hydrate rating / rank for owner + members
const leaderboardDto = await this.driversApiClient.getLeaderboard();
const leaderboardByDriverId = new Map(
leaderboardDto.drivers.map(driver => [driver.id, driver])
);
// Get owner // Get owner
const ownerDriver = await this.driversApiClient.getDriver(league.ownerId); const ownerDriver = await this.driversApiClient.getDriver(league.ownerId);
let owner: DriverSummaryViewModel | null = null; let owner: DriverSummaryViewModel | null = null;
if (ownerDriver) { if (ownerDriver) {
// TODO: get rating and rank from API const ownerStats = leaderboardByDriverId.get(ownerDriver.id);
owner = new DriverSummaryViewModel({ owner = new DriverSummaryViewModel({
driver: ownerDriver, driver: ownerDriver,
rating: null, // TODO: get from API rating: ownerStats?.rating ?? null,
rank: null, // TODO: get from API rank: ownerStats?.rank ?? null,
}); });
} }
@@ -62,10 +67,11 @@ export class LeagueSettingsService {
if (member.driverId !== league.ownerId && member.role !== 'owner') { if (member.driverId !== league.ownerId && member.role !== 'owner') {
const driver = await this.driversApiClient.getDriver(member.driverId); const driver = await this.driversApiClient.getDriver(member.driverId);
if (driver) { if (driver) {
const memberStats = leaderboardByDriverId.get(driver.id);
members.push(new DriverSummaryViewModel({ members.push(new DriverSummaryViewModel({
driver, driver,
rating: null, // TODO: get from API rating: memberStats?.rating ?? null,
rank: null, // TODO: get from API rank: memberStats?.rank ?? null,
})); }));
} }
} }

View File

@@ -1,12 +1,10 @@
import { RequestAvatarGenerationInputDTO } from '@/lib/types/generated/RequestAvatarGenerationInputDTO'; import { RequestAvatarGenerationInputDTO } from '@/lib/types/generated/RequestAvatarGenerationInputDTO';
import { UpdateAvatarInputDTO } from '@/lib/types/generated/UpdateAvatarInputDTO';
import { AvatarViewModel } from '@/lib/view-models/AvatarViewModel'; import { AvatarViewModel } from '@/lib/view-models/AvatarViewModel';
import { RequestAvatarGenerationViewModel } from '@/lib/view-models/RequestAvatarGenerationViewModel'; import { RequestAvatarGenerationViewModel } from '@/lib/view-models/RequestAvatarGenerationViewModel';
import { UpdateAvatarViewModel } from '@/lib/view-models/UpdateAvatarViewModel'; import { UpdateAvatarViewModel } from '@/lib/view-models/UpdateAvatarViewModel';
import type { MediaApiClient } from '../../api/media/MediaApiClient'; import type { MediaApiClient } from '../../api/media/MediaApiClient';
// TODO: Move these types to apps/website/lib/types/generated when available
type UpdateAvatarInputDto = { driverId: string; avatarUrl: string };
/** /**
* Avatar Service * Avatar Service
* *
@@ -37,7 +35,7 @@ export class AvatarService {
/** /**
* Update avatar for driver with view model transformation * Update avatar for driver with view model transformation
*/ */
async updateAvatar(input: UpdateAvatarInputDto): Promise<UpdateAvatarViewModel> { async updateAvatar(input: UpdateAvatarInputDTO): Promise<UpdateAvatarViewModel> {
const dto = await this.apiClient.updateAvatar(input); const dto = await this.apiClient.updateAvatar(input);
return new UpdateAvatarViewModel(dto); return new UpdateAvatarViewModel(dto);
} }

View File

@@ -3,8 +3,8 @@ import { MediaViewModel } from '@/lib/view-models/MediaViewModel';
import { UploadMediaViewModel } from '@/lib/view-models/UploadMediaViewModel'; import { UploadMediaViewModel } from '@/lib/view-models/UploadMediaViewModel';
import type { MediaApiClient } from '../../api/media/MediaApiClient'; import type { MediaApiClient } from '../../api/media/MediaApiClient';
// TODO: Move these types to apps/website/lib/types/generated when available // Local request shape mirroring the media upload API contract until a generated type is available
type UploadMediaInputDto = { file: File; type: string; category?: string }; type UploadMediaRequest = { file: File; type: string; category?: string };
/** /**
* Media Service * Media Service
@@ -20,7 +20,7 @@ export class MediaService {
/** /**
* Upload media file with view model transformation * Upload media file with view model transformation
*/ */
async uploadMedia(input: UploadMediaInputDto): Promise<UploadMediaViewModel> { async uploadMedia(input: UploadMediaRequest): Promise<UploadMediaViewModel> {
const dto = await this.apiClient.uploadMedia(input); const dto = await this.apiClient.uploadMedia(input);
return new UploadMediaViewModel(dto); return new UploadMediaViewModel(dto);
} }

View File

@@ -1,11 +1,12 @@
import { MembershipFeeDto } from '@/lib/types/generated/MembershipFeeDto'; import { MembershipFeeDto } from '@/lib/types/generated/MembershipFeeDto';
import type { MemberPaymentDto } from '@/lib/types/generated/MemberPaymentDto';
import { MembershipFeeViewModel } from '@/lib/view-models/MembershipFeeViewModel'; import { MembershipFeeViewModel } from '@/lib/view-models/MembershipFeeViewModel';
import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient'; import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
// TODO: This DTO should be generated from OpenAPI spec when the endpoint is added // Response shape as returned by the membership-fees payments endpoint; mirrors the API contract until a generated type is introduced
export interface GetMembershipFeesOutputDto { export interface GetMembershipFeesOutputDto {
fee: MembershipFeeDto | null; fee: MembershipFeeDto | null;
payments: import('./MemberPaymentDto').MemberPaymentDto[]; payments: MemberPaymentDto[];
} }
/** /**
@@ -22,11 +23,12 @@ export class MembershipFeeService {
/** /**
* Get membership fees by league ID with view model transformation * Get membership fees by league ID with view model transformation
*/ */
async getMembershipFees(leagueId: string): Promise<{ fee: MembershipFeeViewModel | null; payments: any[] }> { async getMembershipFees(leagueId: string): Promise<{ fee: MembershipFeeViewModel | null; payments: MemberPaymentDto[] }> {
const dto = await this.apiClient.getMembershipFees({ leagueId }); const dto: GetMembershipFeesOutputDto = await this.apiClient.getMembershipFees({ leagueId });
return { return {
fee: dto.fee ? new MembershipFeeViewModel(dto.fee) : null, fee: dto.fee ? new MembershipFeeViewModel(dto.fee) : null,
payments: dto.payments // TODO: map to view models if needed // Expose raw member payment DTOs; callers may map these into UI-specific view models if needed
payments: dto.payments,
}; };
} }
} }

View File

@@ -6,8 +6,8 @@ import type { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
import type { PaymentDTO } from '../../types/generated/PaymentDto'; import type { PaymentDTO } from '../../types/generated/PaymentDto';
import type { PrizeDto } from '../../types/generated/PrizeDto'; import type { PrizeDto } from '../../types/generated/PrizeDto';
// TODO: Move these types to apps/website/lib/types/generated when available // Local payment creation request matching the Payments API contract until a shared generated type is introduced
type CreatePaymentInputDto = { type CreatePaymentRequest = {
type: 'sponsorship' | 'membership_fee'; type: 'sponsorship' | 'membership_fee';
amount: number; amount: number;
payerId: string; payerId: string;
@@ -53,7 +53,7 @@ export class PaymentService {
/** /**
* Create a new payment * Create a new payment
*/ */
async createPayment(input: CreatePaymentInputDto): Promise<PaymentViewModel> { async createPayment(input: CreatePaymentRequest): Promise<PaymentViewModel> {
const dto = await this.apiClient.createPayment(input); const dto = await this.apiClient.createPayment(input);
return new PaymentViewModel(dto.payment); return new PaymentViewModel(dto.payment);
} }

View File

@@ -2,20 +2,7 @@ import { RacesApiClient } from '../../api/races/RacesApiClient';
import { RaceDetailViewModel } from '../../view-models/RaceDetailViewModel'; import { RaceDetailViewModel } from '../../view-models/RaceDetailViewModel';
import { RacesPageViewModel } from '../../view-models/RacesPageViewModel'; import { RacesPageViewModel } from '../../view-models/RacesPageViewModel';
import { RaceStatsViewModel } from '../../view-models/RaceStatsViewModel'; import { RaceStatsViewModel } from '../../view-models/RaceStatsViewModel';
import type { RaceStatsDTO } from '../../types/generated/RaceStatsDTO';
// TODO: Move these types to apps/website/lib/types/generated when available
type RacesPageDataRaceDTO = {
id: string;
track: string;
car: string;
scheduledAt: string;
status: string;
leagueId: string;
leagueName: string;
};
type RacesPageDataDto = { races: RacesPageDataRaceDTO[] };
type RaceStatsDTO = { totalRaces: number };
/** /**
* Race Service * Race Service
* *
@@ -94,11 +81,12 @@ export class RaceService {
/** /**
* Find races by league ID * Find races by league ID
*
* The races API does not currently expose a league-filtered listing endpoint in this build,
* so this method deliberately signals that the operation is unavailable instead of making
* assumptions about URL structure.
*/ */
async findByLeagueId(leagueId: string): Promise<any[]> { async findByLeagueId(_leagueId: string): Promise<never> {
// Assuming the API has /races?leagueId=... throw new Error('Finding races by league ID is not supported in this build');
// TODO: Update when API is implemented
const dto = await this.apiClient.get('/races?leagueId=' + leagueId) as { races: any[] };
return dto.races;
} }
} }

View File

@@ -1,7 +1,8 @@
import { TeamJoinRequestViewModel, type TeamJoinRequestDTO } from '@/lib/view-models/TeamJoinRequestViewModel'; import { TeamJoinRequestViewModel, type TeamJoinRequestDTO } from '@/lib/view-models/TeamJoinRequestViewModel';
import type { TeamsApiClient } from '../../api/teams/TeamsApiClient'; import type { TeamsApiClient } from '../../api/teams/TeamsApiClient';
// TODO: Create generated DTO when API spec is available // Wrapper for the team join requests collection returned by the teams API in this build
// Mirrors the current API response shape until a generated DTO is available.
type TeamJoinRequestsDto = { type TeamJoinRequestsDto = {
requests: TeamJoinRequestDTO[]; requests: TeamJoinRequestDTO[];
}; };
@@ -27,17 +28,21 @@ export class TeamJoinService {
/** /**
* Approve a team join request * Approve a team join request
*
* The teams API currently exposes read-only join requests in this build; approving
* a request requires a future management endpoint, so this method fails explicitly.
*/ */
async approveJoinRequest(): Promise<void> { async approveJoinRequest(): Promise<never> {
// TODO: implement API call when endpoint is available throw new Error('Approving team join requests is not supported in this build');
throw new Error('Not implemented: API endpoint for approving join requests');
} }
/** /**
* Reject a team join request * Reject a team join request
*
* Rejection of join requests is also not available yet on the backend, so callers
* must treat this as an unsupported operation rather than a silent no-op.
*/ */
async rejectJoinRequest(): Promise<void> { async rejectJoinRequest(): Promise<never> {
// TODO: implement API call when endpoint is available throw new Error('Rejecting team join requests is not supported in this build');
throw new Error('Not implemented: API endpoint for rejecting join requests');
} }
} }

View File

@@ -87,17 +87,26 @@ export class TeamService {
/** /**
* Remove a driver from the team * Remove a driver from the team
*
* The backend does not yet expose a dedicated endpoint for removing team memberships,
* so this method fails explicitly to avoid silently ignoring removal requests.
*/ */
async removeMembership(teamId: string, driverId: string): Promise<void> { async removeMembership(teamId: string, driverId: string): Promise<void> {
// TODO: Implement when API endpoint is available void teamId;
throw new Error('Not implemented: API endpoint for removing team membership'); void driverId;
throw new Error('Team membership removal is not supported in this build');
} }
/** /**
* Update team membership role * Update team membership role
*
* Role updates for team memberships are not supported by the current API surface;
* callers must treat this as an unavailable operation.
*/ */
async updateMembership(teamId: string, driverId: string, role: string): Promise<void> { async updateMembership(teamId: string, driverId: string, role: string): Promise<void> {
// TODO: Implement when API endpoint is available void teamId;
throw new Error('Not implemented: API endpoint for updating team membership role'); void driverId;
void role;
throw new Error('Team membership role updates are not supported in this build');
} }
} }

View File

@@ -1,26 +1,37 @@
/** /**
* Site-wide configuration for GridPilot website * Site-wide configuration for GridPilot website.
* *
* IMPORTANT: Update this file with correct information before going live. * Values are primarily sourced from environment variables so that
* This serves as a single source of truth for legal and company information. * deployments can provide real company details without hard-coding
* production data in the repository.
*/ */
const env = {
platformName: process.env.NEXT_PUBLIC_SITE_NAME,
platformUrl: process.env.NEXT_PUBLIC_SITE_URL,
supportEmail: process.env.NEXT_PUBLIC_SUPPORT_EMAIL,
sponsorEmail: process.env.NEXT_PUBLIC_SPONSOR_EMAIL,
legalCompanyName: process.env.NEXT_PUBLIC_LEGAL_COMPANY_NAME,
legalVatId: process.env.NEXT_PUBLIC_LEGAL_VAT_ID,
legalRegisteredCountry: process.env.NEXT_PUBLIC_LEGAL_REGISTERED_COUNTRY,
legalRegisteredAddress: process.env.NEXT_PUBLIC_LEGAL_REGISTERED_ADDRESS,
} as const;
export const siteConfig = { export const siteConfig = {
// Platform Information // Platform Information
platformName: 'GridPilot', platformName: env.platformName ?? 'GridPilot',
platformUrl: process.env.NEXT_PUBLIC_SITE_URL || 'https://gridpilot.com', platformUrl: env.platformUrl ?? 'https://gridpilot.com',
// Contact Information // Contact Information
supportEmail: 'support@gridpilot.com', supportEmail: env.supportEmail ?? 'support@example.com',
sponsorEmail: 'sponsors@gridpilot.com', sponsorEmail: env.sponsorEmail ?? 'sponsors@example.com',
// Legal & Business Information // Legal & Business Information
// TODO: Update these with actual company details before launch
legal: { legal: {
companyName: '', // e.g., 'GridPilot GmbH' - leave empty until confirmed companyName: env.legalCompanyName ?? '',
vatId: '', // e.g., 'DE123456789' - leave empty until confirmed vatId: env.legalVatId ?? '',
registeredCountry: '', // e.g., 'Germany' - leave empty until confirmed registeredCountry: env.legalRegisteredCountry ?? '',
registeredAddress: '', // Full registered address - leave empty until confirmed registeredAddress: env.legalRegisteredAddress ?? '',
}, },
// Platform Fees // Platform Fees

View File

@@ -0,0 +1,9 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface ActivityItemDTO {
id: string;
}

View File

@@ -0,0 +1,15 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface AllRacesListItemDTO {
id: string;
track: string;
car: string;
scheduledAt: string;
status: string;
leagueId: string;
leagueName: string;
}

View File

@@ -0,0 +1,13 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface AvailableLeagueDTO {
id: string;
name: string;
game: string;
drivers: number;
avgViewsPerRace: number;
}

View File

@@ -0,0 +1,14 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface BillingStatsDTO {
totalSpent: number;
pendingAmount: number;
nextPaymentDate: string;
nextPaymentAmount: number;
activeSponsorships: number;
averageMonthlySpend: number;
}

View File

@@ -11,4 +11,6 @@ export interface DashboardRaceSummaryDTO {
track: string; track: string;
car: string; car: string;
scheduledAt: string; scheduledAt: string;
status: string;
isMyLeague: boolean;
} }

View File

@@ -0,0 +1,10 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface GetEntitySponsorshipPricingResultDTO {
entityType: string;
entityId: string;
}

View File

@@ -0,0 +1,15 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface GetLeagueWalletOutputDTO {
balance: number;
currency: string;
totalRevenue: number;
totalFees: number;
totalWithdrawals: number;
pendingPayouts: number;
canWithdraw: boolean;
}

View File

@@ -0,0 +1,10 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface GetRaceDetailParamsDTO {
raceId: string;
driverId: string;
}

View File

@@ -0,0 +1,12 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface ImportRaceResultsSummaryDTO {
success: boolean;
raceId: string;
driversProcessed: number;
resultsRecorded: number;
}

View File

@@ -0,0 +1,15 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface InvoiceDTO {
id: string;
invoiceNumber: string;
date: string;
dueDate: string;
amount: number;
vatAmount: number;
totalAmount: number;
}

View File

@@ -4,20 +4,6 @@
* Do not edit manually - regenerate using: npm run api:sync-types * Do not edit manually - regenerate using: npm run api:sync-types
*/ */
import type { LeagueConfigFormModelBasicsDTO } from './LeagueConfigFormModelBasicsDTO';
import type { LeagueConfigFormModelStructureDTO } from './LeagueConfigFormModelStructureDTO';
import type { LeagueConfigFormModelScoringDTO } from './LeagueConfigFormModelScoringDTO';
import type { LeagueConfigFormModelDropPolicyDTO } from './LeagueConfigFormModelDropPolicyDTO';
import type { LeagueConfigFormModelTimingsDTO } from './LeagueConfigFormModelTimingsDTO';
import type { LeagueConfigFormModelStewardingDTO } from './LeagueConfigFormModelStewardingDTO';
export interface LeagueConfigFormModelDTO { export interface LeagueConfigFormModelDTO {
leagueId: string; leagueId: string;
basics: LeagueConfigFormModelBasicsDTO;
structure: LeagueConfigFormModelStructureDTO;
championships: any[];
scoring: LeagueConfigFormModelScoringDTO;
dropPolicy: LeagueConfigFormModelDropPolicyDTO;
timings: LeagueConfigFormModelTimingsDTO;
stewarding: LeagueConfigFormModelStewardingDTO;
} }

View File

@@ -0,0 +1,11 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface LeagueDetailDTO {
id: string;
name: string;
game: string;
}

View File

@@ -0,0 +1,11 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface LeagueMembershipDTO {
id: string;
leagueId: string;
driverId: string;
}

View File

@@ -0,0 +1,11 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface LeagueScoringPresetDTO {
id: string;
name: string;
description: string;
}

View File

@@ -7,4 +7,10 @@
export interface LeagueWithCapacityDTO { export interface LeagueWithCapacityDTO {
id: string; id: string;
name: string; name: string;
description: string;
ownerId: string;
settings: Record<string, unknown>;
maxDrivers: number;
sessionDuration?: number;
visibility?: string;
} }

View File

@@ -0,0 +1,14 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface NotificationSettingsDTO {
emailNewSponsorships: boolean;
emailWeeklyReport: boolean;
emailRaceAlerts: boolean;
emailPaymentAlerts: boolean;
emailNewOpportunities: boolean;
emailContractExpiry: boolean;
}

View File

@@ -0,0 +1,9 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface PaymentMethodDTO {
id: string;
}

View File

@@ -0,0 +1,12 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface PrivacySettingsDTO {
publicProfile: boolean;
showStats: boolean;
showActiveSponsorships: boolean;
allowDirectContact: boolean;
}

View File

@@ -6,6 +6,7 @@
export interface ProtestDTO { export interface ProtestDTO {
id: string; id: string;
leagueId: string;
raceId: string; raceId: string;
protestingDriverId: string; protestingDriverId: string;
accusedDriverId: string; accusedDriverId: string;

View File

@@ -0,0 +1,10 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface RenewalAlertDTO {
id: string;
name: string;
}

View File

@@ -0,0 +1,14 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface SponsorProfileDTO {
companyName: string;
contactName: string;
contactEmail: string;
contactPhone: string;
website: string;
description: string;
}

View File

@@ -0,0 +1,9 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface SponsorshipDTO {
id: string;
}

View File

@@ -0,0 +1,9 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface TotalLeaguesDTO {
totalLeagues: number;
}

View File

@@ -0,0 +1,9 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface WalletTransactionDTO {
id: string;
}

View File

@@ -0,0 +1,12 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface WithdrawFromLeagueWalletInputDTO {
amount: number;
currency: string;
seasonId: string;
destinationAccount: string;
}

View File

@@ -0,0 +1,9 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface WithdrawFromLeagueWalletOutputDTO {
success: boolean;
}

View File

@@ -1,50 +1,10 @@
// TODO: Move this business logic to core domain layer - scoring presets and their timing rules are domain concepts import type { ScoringPresetTimings } from '@core/racing/domain/services/ScoringPresetTimingService';
import { applyScoringPresetToTimings } from '@core/racing/domain/services/ScoringPresetTimingService';
type Timings = { export type Timings = ScoringPresetTimings;
practiceMinutes?: number;
qualifyingMinutes?: number;
sprintRaceMinutes?: number;
mainRaceMinutes?: number;
sessionCount?: number;
roundsPlanned?: number;
raceDayOfWeek?: number;
raceTimeUtc?: string;
};
export class ScoringPresetApplier { export class ScoringPresetApplier {
static applyToTimings(patternId: string, currentTimings: Timings): Timings { static applyToTimings(patternId: string, currentTimings: Timings): Timings {
const lowerPresetId = patternId.toLowerCase(); return applyScoringPresetToTimings(patternId, currentTimings);
let updatedTimings: Timings = { ...currentTimings };
if (lowerPresetId.includes('sprint') || lowerPresetId.includes('double')) {
updatedTimings = {
...updatedTimings,
practiceMinutes: 15,
qualifyingMinutes: 20,
sprintRaceMinutes: 20,
mainRaceMinutes: 35,
sessionCount: 2,
};
} else if (lowerPresetId.includes('endurance') || lowerPresetId.includes('long')) {
updatedTimings = {
...updatedTimings,
practiceMinutes: 30,
qualifyingMinutes: 30,
mainRaceMinutes: 90,
sessionCount: 1,
};
delete (updatedTimings as { sprintRaceMinutes?: number }).sprintRaceMinutes;
} else {
updatedTimings = {
...updatedTimings,
practiceMinutes: 20,
qualifyingMinutes: 30,
mainRaceMinutes: 40,
sessionCount: 1,
};
delete (updatedTimings as { sprintRaceMinutes?: number }).sprintRaceMinutes;
}
return updatedTimings;
} }
} }

View File

@@ -167,23 +167,25 @@ export class LeagueDetailPageViewModel {
const driver = this.drivers.find(d => d.id === driverId); const driver = this.drivers.find(d => d.id === driverId);
if (!driver) return null; if (!driver) return null;
// TODO: Get driver stats and rankings from service // Detailed rating and rank data are not wired from the analytics services yet;
// For now, return basic info // expose the driver identity only so the UI can still render role assignments.
return { return {
driver, driver,
rating: null, // TODO: fetch from service rating: null,
rank: null, // TODO: fetch from service rank: null,
}; };
} }
// UI helper methods // UI helper methods
get isSponsorMode(): boolean { get isSponsorMode(): boolean {
// TODO: implement sponsor mode check // League detail pages are rendered in organizer mode in this build; sponsor-specific
// mode switches will be introduced once sponsor dashboards share this view model.
return false; return false;
} }
get currentUserMembership(): LeagueMembershipWithRole | null { get currentUserMembership(): LeagueMembershipWithRole | null {
// TODO: get current user ID and find membership // Current user identity is not available in this view model context yet; callers must
// pass an explicit membership if they need per-user permissions.
return null; return null;
} }

View File

@@ -22,7 +22,7 @@ export class ProtestViewModel {
this.accusedDriverId = dto.accusedDriverId; this.accusedDriverId = dto.accusedDriverId;
this.description = dto.description; this.description = dto.description;
this.submittedAt = dto.submittedAt; this.submittedAt = dto.submittedAt;
// TODO: Add these fields to DTO when available // Status and decision metadata are not part of the protest DTO in this build; they default to a pending, unreviewed protest
this.status = 'pending'; this.status = 'pending';
this.reviewedAt = undefined; this.reviewedAt = undefined;
this.decisionNotes = undefined; this.decisionNotes = undefined;

View File

@@ -9,7 +9,6 @@ export class RaceWithSOFViewModel {
this.track = dto.track; this.track = dto.track;
} }
// TODO: Add additional fields when RaceWithSOFDTO is updated in OpenAPI spec // The view model currently exposes only basic race identity and track information.
// sof?: number; // Additional strength-of-field or result details can be added here once the DTO carries them.
// results?: RaceResultViewModel[];
} }

View File

@@ -0,0 +1,103 @@
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { SponsorshipDetailOutput } from '../ports/output/SponsorSponsorshipsOutputPort';
export interface GetSeasonSponsorshipsParams {
seasonId: string;
}
export interface GetSeasonSponsorshipsOutputPort {
seasonId: string;
sponsorships: SponsorshipDetailOutput[];
}
export class GetSeasonSponsorshipsUseCase
implements AsyncUseCase<GetSeasonSponsorshipsParams, GetSeasonSponsorshipsOutputPort | null, 'REPOSITORY_ERROR'>
{
constructor(
private readonly seasonSponsorshipRepository: ISeasonSponsorshipRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly raceRepository: IRaceRepository,
) {}
async execute(
params: GetSeasonSponsorshipsParams,
): Promise<Result<GetSeasonSponsorshipsOutputPort | null, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
try {
const { seasonId } = params;
const season = await this.seasonRepository.findById(seasonId);
if (!season) {
return Result.ok(null);
}
const league = await this.leagueRepository.findById(season.leagueId);
if (!league) {
return Result.ok(null);
}
const sponsorships = await this.seasonSponsorshipRepository.findBySeasonId(seasonId);
// Pre-compute metrics shared across all sponsorships in this season
const memberships = await this.leagueMembershipRepository.getLeagueMembers(season.leagueId);
const driverCount = memberships.length;
const races = await this.raceRepository.findByLeagueId(season.leagueId);
const raceCount = races.length;
const completedRaces = races.filter(r => r.status === 'completed').length;
const impressions = completedRaces * driverCount * 100;
const sponsorshipDetails: SponsorshipDetailOutput[] = sponsorships.map(sponsorship => {
const platformFee = sponsorship.getPlatformFee();
const netAmount = sponsorship.getNetAmount();
return {
id: sponsorship.id,
leagueId: league.id,
leagueName: league.name,
seasonId: season.id,
seasonName: season.name,
...(season.startDate !== undefined ? { seasonStartDate: season.startDate } : {}),
...(season.endDate !== undefined ? { seasonEndDate: season.endDate } : {}),
tier: sponsorship.tier,
status: sponsorship.status,
pricing: {
amount: sponsorship.pricing.amount,
currency: sponsorship.pricing.currency,
},
platformFee: {
amount: platformFee.amount,
currency: platformFee.currency,
},
netAmount: {
amount: netAmount.amount,
currency: netAmount.currency,
},
metrics: {
drivers: driverCount,
races: raceCount,
completedRaces,
impressions,
},
createdAt: sponsorship.createdAt,
...(sponsorship.activatedAt !== undefined ? { activatedAt: sponsorship.activatedAt } : {}),
};
});
return Result.ok({
seasonId,
sponsorships: sponsorshipDetails,
});
} catch {
return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch season sponsorships' });
}
}
}

View File

@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest';
import {
applyScoringPresetToTimings,
type ScoringPresetTimings,
} from '@core/racing/domain/services/ScoringPresetTimingService';
describe('ScoringPresetTimingService', () => {
it('applies sprint/double style presets with two sessions and sprint minutes', () => {
const initial: ScoringPresetTimings = {
practiceMinutes: 5,
qualifyingMinutes: 10,
mainRaceMinutes: 20,
sessionCount: 1,
};
const result = applyScoringPresetToTimings('Sprint-Main-Double', initial);
expect(result.practiceMinutes).toBe(15);
expect(result.qualifyingMinutes).toBe(20);
expect(result.sprintRaceMinutes).toBe(20);
expect(result.mainRaceMinutes).toBe(35);
expect(result.sessionCount).toBe(2);
});
it('applies endurance/long style presets with single main session and no sprint', () => {
const initial: ScoringPresetTimings = {
practiceMinutes: 10,
qualifyingMinutes: 15,
sprintRaceMinutes: 10,
mainRaceMinutes: 30,
sessionCount: 2,
};
const result = applyScoringPresetToTimings('Endurance-Main', initial);
expect(result.practiceMinutes).toBe(30);
expect(result.qualifyingMinutes).toBe(30);
expect(result.mainRaceMinutes).toBe(90);
expect(result.sessionCount).toBe(1);
expect(result.sprintRaceMinutes).toBeUndefined();
});
it('applies default timing rules for non-matching presets and clears sprint minutes', () => {
const initial: ScoringPresetTimings = {
practiceMinutes: 10,
qualifyingMinutes: 15,
sprintRaceMinutes: 10,
mainRaceMinutes: 30,
sessionCount: 2,
};
const result = applyScoringPresetToTimings('club-default', initial);
expect(result.practiceMinutes).toBe(20);
expect(result.qualifyingMinutes).toBe(30);
expect(result.mainRaceMinutes).toBe(40);
expect(result.sessionCount).toBe(1);
expect(result.sprintRaceMinutes).toBeUndefined();
});
it('treats pattern id matching as case-insensitive', () => {
const initial: ScoringPresetTimings = {};
const lower = applyScoringPresetToTimings('endurance-main', initial);
const upper = applyScoringPresetToTimings('ENDURANCE-MAIN', initial);
expect(lower).toEqual(upper);
});
});

View File

@@ -0,0 +1,56 @@
export type ScoringPresetTimings = {
practiceMinutes?: number;
qualifyingMinutes?: number;
sprintRaceMinutes?: number;
mainRaceMinutes?: number;
sessionCount?: number;
roundsPlanned?: number;
raceDayOfWeek?: number;
raceTimeUtc?: string;
};
/**
* Apply high-level scoring preset semantics to league/session timings.
*
* This encapsulates the mapping between logical scoring presets (sprint, endurance, etc.)
* and their default timing configuration so that UI layers do not need to duplicate
* or interpret preset IDs directly.
*/
export function applyScoringPresetToTimings(
patternId: string,
currentTimings: ScoringPresetTimings,
): ScoringPresetTimings {
const lowerPresetId = patternId.toLowerCase();
let updatedTimings: ScoringPresetTimings = { ...currentTimings };
if (lowerPresetId.includes('sprint') || lowerPresetId.includes('double')) {
updatedTimings = {
...updatedTimings,
practiceMinutes: 15,
qualifyingMinutes: 20,
sprintRaceMinutes: 20,
mainRaceMinutes: 35,
sessionCount: 2,
};
} else if (lowerPresetId.includes('endurance') || lowerPresetId.includes('long')) {
updatedTimings = {
...updatedTimings,
practiceMinutes: 30,
qualifyingMinutes: 30,
mainRaceMinutes: 90,
sessionCount: 1,
};
delete (updatedTimings as { sprintRaceMinutes?: number }).sprintRaceMinutes;
} else {
updatedTimings = {
...updatedTimings,
practiceMinutes: 20,
qualifyingMinutes: 30,
mainRaceMinutes: 40,
sessionCount: 1,
};
delete (updatedTimings as { sprintRaceMinutes?: number }).sprintRaceMinutes;
}
return updatedTimings;
}