From 5f3712e5ab3bba05498e94fa53171ecc1c70d932 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Wed, 21 Jan 2026 00:53:29 +0100 Subject: [PATCH] website refactor --- .env.development | 2 +- adapters/bootstrap/SeedRacingData.ts | 4 + .../typeorm/mappers/DriverStatsOrmMapper.ts | 46 +++--- apps/api/src/domain/driver/DriverProviders.ts | 38 +---- apps/api/src/domain/team/Team.http.test.ts | 21 +++ apps/api/src/domain/team/TeamProviders.ts | 15 +- apps/api/src/domain/team/TeamTokens.ts | 1 + .../use-cases/DriverStatsUseCase.ts | 4 +- .../GetTeamsLeaderboardUseCase.test.ts | 138 ------------------ .../application/use-cases/RankingUseCase.ts | 17 ++- 10 files changed, 85 insertions(+), 201 deletions(-) delete mode 100644 core/racing/application/use-cases/GetTeamsLeaderboardUseCase.test.ts diff --git a/.env.development b/.env.development index e4cd07d2a..ac0e38de8 100644 --- a/.env.development +++ b/.env.development @@ -13,7 +13,7 @@ NEXT_TELEMETRY_DISABLED=1 # API (NestJS) # ------------------------------------------ # API persistence is inferred from DATABASE_URL by default. -# GRIDPILOT_API_PERSISTENCE=postgres +GRIDPILOT_API_PERSISTENCE=postgres DATABASE_URL=postgres://gridpilot_user:gridpilot_dev_pass@db:5432/gridpilot_dev diff --git a/adapters/bootstrap/SeedRacingData.ts b/adapters/bootstrap/SeedRacingData.ts index c2272fdca..a448e79a6 100644 --- a/adapters/bootstrap/SeedRacingData.ts +++ b/adapters/bootstrap/SeedRacingData.ts @@ -101,6 +101,10 @@ export class SeedRacingData { if (existingDrivers.length > 0 && !forceReseed) { this.logger.info('[Bootstrap] Racing seed skipped (drivers already exist), ensuring scoring configs'); await this.ensureScoringConfigsForExistingData(); + // Even when skipping full seed, ensure stats are computed and stored + this.logger.info('[Bootstrap] Computing and storing driver/team stats for existing data'); + await this.computeAndStoreDriverStats(); + await this.computeAndStoreTeamStats(); return; } diff --git a/adapters/racing/persistence/typeorm/mappers/DriverStatsOrmMapper.ts b/adapters/racing/persistence/typeorm/mappers/DriverStatsOrmMapper.ts index a18fa79b3..768b8fab4 100644 --- a/adapters/racing/persistence/typeorm/mappers/DriverStatsOrmMapper.ts +++ b/adapters/racing/persistence/typeorm/mappers/DriverStatsOrmMapper.ts @@ -31,33 +31,33 @@ export class DriverStatsOrmMapper { const entityName = 'DriverStats'; assertNonEmptyString(entityName, 'driverId', entity.driverId); - assertInteger(entityName, 'rating', entity.rating); - assertInteger(entityName, 'safetyRating', entity.safetyRating); - assertInteger(entityName, 'sportsmanshipRating', entity.sportsmanshipRating); - assertInteger(entityName, 'totalRaces', entity.totalRaces); - assertInteger(entityName, 'wins', entity.wins); - assertInteger(entityName, 'podiums', entity.podiums); - assertInteger(entityName, 'dnfs', entity.dnfs); - assertNumber(entityName, 'avgFinish', entity.avgFinish); - assertInteger(entityName, 'bestFinish', entity.bestFinish); - assertInteger(entityName, 'worstFinish', entity.worstFinish); - assertInteger(entityName, 'consistency', entity.consistency); + assertInteger(entityName, 'rating', Number(entity.rating)); + assertInteger(entityName, 'safetyRating', Number(entity.safetyRating)); + assertNumber(entityName, 'sportsmanshipRating', Number(entity.sportsmanshipRating)); + assertInteger(entityName, 'totalRaces', Number(entity.totalRaces)); + assertInteger(entityName, 'wins', Number(entity.wins)); + assertInteger(entityName, 'podiums', Number(entity.podiums)); + assertInteger(entityName, 'dnfs', Number(entity.dnfs)); + assertNumber(entityName, 'avgFinish', Number(entity.avgFinish)); + assertInteger(entityName, 'bestFinish', Number(entity.bestFinish)); + assertInteger(entityName, 'worstFinish', Number(entity.worstFinish)); + assertInteger(entityName, 'consistency', Number(entity.consistency)); assertNonEmptyString(entityName, 'experienceLevel', entity.experienceLevel); const result: DriverStats = { - rating: entity.rating, - safetyRating: entity.safetyRating, - sportsmanshipRating: entity.sportsmanshipRating, - totalRaces: entity.totalRaces, - wins: entity.wins, - podiums: entity.podiums, - dnfs: entity.dnfs, - avgFinish: entity.avgFinish, - bestFinish: entity.bestFinish, - worstFinish: entity.worstFinish, - consistency: entity.consistency, + rating: Number(entity.rating), + safetyRating: Number(entity.safetyRating), + sportsmanshipRating: Number(entity.sportsmanshipRating), + totalRaces: Number(entity.totalRaces), + wins: Number(entity.wins), + podiums: Number(entity.podiums), + dnfs: Number(entity.dnfs), + avgFinish: Number(entity.avgFinish), + bestFinish: Number(entity.bestFinish), + worstFinish: Number(entity.worstFinish), + consistency: Number(entity.consistency), experienceLevel: entity.experienceLevel, - overallRank: entity.overallRank ?? null, + overallRank: entity.overallRank ? Number(entity.overallRank) : null, }; return result; diff --git a/apps/api/src/domain/driver/DriverProviders.ts b/apps/api/src/domain/driver/DriverProviders.ts index 968a14925..e4346451c 100644 --- a/apps/api/src/domain/driver/DriverProviders.ts +++ b/apps/api/src/domain/driver/DriverProviders.ts @@ -32,8 +32,6 @@ import { InMemoryDriverExtendedProfileProvider } from '@adapters/racing/ports/In import { InMemoryDriverRatingProvider } from '@adapters/racing/ports/InMemoryDriverRatingProvider'; // Import new use cases // Import new repositories -import { InMemoryDriverStatsRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverStatsRepository'; -import { InMemoryMediaRepository } from '@adapters/racing/persistence/media/InMemoryMediaRepository'; // Import MediaResolverAdapter import { MediaResolverAdapter } from '@adapters/media/MediaResolverAdapter'; // Import repository tokens @@ -67,7 +65,6 @@ import { IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN, LIVERY_REPOSITORY_TOKEN, LOGGER_TOKEN, - MEDIA_REPOSITORY_TOKEN, MEDIA_RESOLVER_TOKEN, NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN, RACE_REGISTRATION_REPOSITORY_TOKEN, @@ -133,48 +130,25 @@ export const DriverProviders: Provider[] = createLoggedProviders([ }, // Repositories (racing + social repos are provided by imported persistence modules) - { - provide: DRIVER_STATS_REPOSITORY_TOKEN, - useFactory: (logger: Logger) => new InMemoryDriverStatsRepository(logger), - inject: [LOGGER_TOKEN], - }, - { - provide: MEDIA_REPOSITORY_TOKEN, - useFactory: (logger: Logger) => { - const mediaRepo = new InMemoryMediaRepository(logger); - - // Override getTeamLogo to provide fallback URLs - const originalGetTeamLogo = mediaRepo.getTeamLogo.bind(mediaRepo); - mediaRepo.getTeamLogo = async (teamId: string): Promise => { - const logo = await originalGetTeamLogo(teamId); - if (logo) return logo; - - // Fallback: generate deterministic team logo URL - // Use path-only URL - return `/media/teams/${teamId}/logo`; - }; - - return mediaRepo; - }, - inject: [LOGGER_TOKEN], - }, { provide: RANKING_SERVICE_TOKEN, useFactory: ( standingRepo: StandingRepository, driverRepo: DriverRepository, + driverStatsRepo: DriverStatsRepository, logger: Logger - ) => new RankingUseCase(standingRepo, driverRepo, logger), - inject: ['IStandingRepository', DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN], + ) => new RankingUseCase(standingRepo, driverRepo, driverStatsRepo, logger), + inject: ['IStandingRepository', DRIVER_REPOSITORY_TOKEN, DRIVER_STATS_REPOSITORY_TOKEN, LOGGER_TOKEN], }, { provide: DRIVER_STATS_SERVICE_TOKEN, useFactory: ( resultRepo: ResultRepository, standingRepo: StandingRepository, + driverStatsRepo: DriverStatsRepository, logger: Logger - ) => new DriverStatsUseCase(resultRepo, standingRepo, logger), - inject: ['IResultRepository', 'IStandingRepository', LOGGER_TOKEN], + ) => new DriverStatsUseCase(resultRepo, standingRepo, driverStatsRepo, logger), + inject: ['IResultRepository', 'IStandingRepository', DRIVER_STATS_REPOSITORY_TOKEN, LOGGER_TOKEN], }, { provide: DRIVER_RATING_PROVIDER_TOKEN, diff --git a/apps/api/src/domain/team/Team.http.test.ts b/apps/api/src/domain/team/Team.http.test.ts index e24353221..4195acba7 100644 --- a/apps/api/src/domain/team/Team.http.test.ts +++ b/apps/api/src/domain/team/Team.http.test.ts @@ -88,6 +88,27 @@ describe('Team domain (HTTP, module-wiring)', () => { .expect(200); }); + it('returns leaderboard with non-zero ratings and wins', async () => { + const response = await request(app.getHttpServer()) + .get('/teams/leaderboard') + .expect(200); + + expect(response.body).toBeDefined(); + expect(response.body.teams).toBeDefined(); + expect(Array.isArray(response.body.teams)).toBe(true); + + // Verify that teams have non-zero ratings and wins + if (response.body.teams.length > 0) { + const team = response.body.teams[0]; + expect(team).toBeDefined(); + expect(team.rating).not.toBeNull(); + expect(typeof team.rating).toBe('number'); + expect(team.rating).toBeGreaterThan(0); + expect(team.totalWins).toBeGreaterThan(0); + expect(team.totalRaces).toBeGreaterThan(0); + } + }); + it('rejects unauthenticated actor on create team (401)', async () => { await request(app.getHttpServer()) .post('/teams') diff --git a/apps/api/src/domain/team/TeamProviders.ts b/apps/api/src/domain/team/TeamProviders.ts index aabc20545..6407bac7f 100644 --- a/apps/api/src/domain/team/TeamProviders.ts +++ b/apps/api/src/domain/team/TeamProviders.ts @@ -19,6 +19,7 @@ import { TEAM_STATS_REPOSITORY_TOKEN, UPDATE_TEAM_USE_CASE_TOKEN, GET_TEAMS_LEADERBOARD_USE_CASE_TOKEN, + DRIVER_STATS_REPOSITORY_TOKEN, } from './TeamTokens'; export { @@ -165,11 +166,15 @@ export const TeamProviders: Provider[] = [ }, { provide: GET_TEAMS_LEADERBOARD_USE_CASE_TOKEN, - useFactory: (teamRepo: TeamRepository, membershipRepo: TeamMembershipRepository, driverStatsRepo: DriverStatsRepository, logger: Logger) => - new GetTeamsLeaderboardUseCase(teamRepo, membershipRepo, (driverId) => { - const stats = driverStatsRepo.getDriverStatsSync?.(driverId); + useFactory: async (teamRepo: TeamRepository, membershipRepo: TeamMembershipRepository, driverStatsRepo: DriverStatsRepository, logger: Logger) => { + // Pre-fetch all driver stats for efficient lookup + const allStats = await driverStatsRepo.getAllStats(); + + return new GetTeamsLeaderboardUseCase(teamRepo, membershipRepo, (driverId) => { + const stats = allStats.get(driverId); return stats ? { rating: stats.rating, wins: stats.wins, totalRaces: stats.totalRaces } : null; - }, logger), - inject: [TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, 'IDriverStatsRepository', LOGGER_TOKEN], + }, logger); + }, + inject: [TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_STATS_REPOSITORY_TOKEN, LOGGER_TOKEN], }, ]; diff --git a/apps/api/src/domain/team/TeamTokens.ts b/apps/api/src/domain/team/TeamTokens.ts index bd4203fb4..1a7766220 100644 --- a/apps/api/src/domain/team/TeamTokens.ts +++ b/apps/api/src/domain/team/TeamTokens.ts @@ -7,6 +7,7 @@ export const TEAM_STATS_REPOSITORY_TOKEN = 'ITeamStatsRepository'; export const MEDIA_REPOSITORY_TOKEN = 'IMediaRepository'; export const MEDIA_RESOLVER_TOKEN = 'MediaResolverPort'; export const RESULT_REPOSITORY_TOKEN = 'IResultRepository'; +export const DRIVER_STATS_REPOSITORY_TOKEN = 'IDriverStatsRepository'; export const GET_ALL_TEAMS_USE_CASE_TOKEN = Symbol('GET_ALL_TEAMS_USE_CASE_TOKEN'); export const GET_TEAM_DETAILS_USE_CASE_TOKEN = Symbol('GET_TEAM_DETAILS_USE_CASE_TOKEN'); diff --git a/core/racing/application/use-cases/DriverStatsUseCase.ts b/core/racing/application/use-cases/DriverStatsUseCase.ts index 4d289f5aa..eea088087 100644 --- a/core/racing/application/use-cases/DriverStatsUseCase.ts +++ b/core/racing/application/use-cases/DriverStatsUseCase.ts @@ -7,6 +7,7 @@ import type { ResultRepository } from '../../domain/repositories/ResultRepository'; import type { StandingRepository } from '../../domain/repositories/StandingRepository'; +import type { DriverStatsRepository } from '../../domain/repositories/DriverStatsRepository'; import type { Logger } from '@core/shared/domain/Logger'; export interface DriverStats { @@ -29,11 +30,12 @@ export class DriverStatsUseCase { constructor( _resultRepository: ResultRepository, _standingRepository: StandingRepository, + private readonly _driverStatsRepository: DriverStatsRepository, private readonly _logger: Logger, ) {} async getDriverStats(driverId: string): Promise { this._logger.debug(`Getting stats for driver ${driverId}`); - return null; + return this._driverStatsRepository.getDriverStats(driverId); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetTeamsLeaderboardUseCase.test.ts b/core/racing/application/use-cases/GetTeamsLeaderboardUseCase.test.ts deleted file mode 100644 index 048327159..000000000 --- a/core/racing/application/use-cases/GetTeamsLeaderboardUseCase.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { Team } from '../../domain/entities/Team'; -import { - GetTeamsLeaderboardUseCase, - type GetTeamsLeaderboardErrorCode, - type GetTeamsLeaderboardInput -} from './GetTeamsLeaderboardUseCase'; - -import { Logger } from 'vite'; -import { TeamMembershipRepository } from '../../domain/repositories/TeamMembershipRepository'; -import { TeamRepository } from '../../domain/repositories/TeamRepository'; - -describe('GetTeamsLeaderboardUseCase', () => { - let useCase: GetTeamsLeaderboardUseCase; - let teamRepository: { - findAll: Mock; - findById: Mock; - }; - let teamMembershipRepository: { - getTeamMembers: Mock; - }; - let getDriverStats: Mock; - let logger: { - debug: Mock; - info: Mock; - warn: Mock; - error: Mock; - }; - - beforeEach(() => { - teamRepository = { - findAll: vi.fn(), - findById: vi.fn(), - }; - teamMembershipRepository = { - getTeamMembers: vi.fn(), - }; - getDriverStats = vi.fn(); - logger = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }; - useCase = new GetTeamsLeaderboardUseCase( - teamRepository as unknown as TeamRepository, - teamMembershipRepository as unknown as TeamMembershipRepository, - getDriverStats as unknown as (driverId: string) => { rating: number | null; wins: number; totalRaces: number } | null, - logger as unknown as Logger - ); - }); - - it('should return teams leaderboard with calculated stats', async () => { - const team1 = Team.create({ - id: 'team-1', - name: 'Team Alpha', - tag: 'TA', - description: 'Description 1', - ownerId: 'owner-1', - leagues: [], - }); - const team2 = Team.create({ - id: 'team-2', - name: 'Team Beta', - tag: 'TB', - description: 'Description 2', - ownerId: 'owner-2', - leagues: [], - }); - const memberships1 = [ - { teamId: 'team-1', driverId: 'driver-1', role: 'driver' as const, status: 'active' as const, joinedAt: new Date() }, - { teamId: 'team-1', driverId: 'driver-2', role: 'driver' as const, status: 'active' as const, joinedAt: new Date() }, - ]; - const memberships2 = [ - { teamId: 'team-2', driverId: 'driver-3', role: 'driver' as const, status: 'active' as const, joinedAt: new Date() }, - ]; - - teamRepository.findAll.mockResolvedValue([team1, team2]); - teamMembershipRepository.getTeamMembers.mockImplementation((teamId: string) => { - if (teamId === 'team-1') return Promise.resolve(memberships1); - if (teamId === 'team-2') return Promise.resolve(memberships2); - return Promise.resolve([]); - }); - getDriverStats.mockImplementation((driverId: string) => { - if (driverId === 'driver-1') return { rating: 1500, wins: 5, totalRaces: 10 }; - if (driverId === 'driver-2') return { rating: 1600, wins: 3, totalRaces: 8 }; - if (driverId === 'driver-3') return { rating: null, wins: 2, totalRaces: 5 }; - return null; - }); - - const input: GetTeamsLeaderboardInput = { leagueId: 'league-1' }; - - const result = await useCase.execute(input); - - expect(result.isOk()).toBe(true); - const presented = result.unwrap(); - - expect(presented.recruitingCount).toBe(2); // both teams are recruiting - expect(presented.items).toHaveLength(2); - expect(presented.items[0]).toMatchObject({ - team: team1, - memberCount: 2, - rating: 1550, // (1500 + 1600) / 2 - totalWins: 8, - totalRaces: 18, - performanceLevel: expect.any(String), - isRecruiting: true, - }); - expect(presented.items[1]).toMatchObject({ - team: team2, - memberCount: 1, - rating: null, - totalWins: 2, - totalRaces: 5, - performanceLevel: expect.any(String), - isRecruiting: true, - }); - }); - - it('should return error on repository failure', async () => { - const error = new Error('Repository error'); - - teamRepository.findAll.mockRejectedValue(error); - - const input: GetTeamsLeaderboardInput = { leagueId: 'league-1' }; - - const result = await useCase.execute(input); - - expect(result.isErr()).toBe(true); - const err = result.unwrapErr() as ApplicationErrorCode< - GetTeamsLeaderboardErrorCode, - { message: string } - >; - expect(err.code).toBe('REPOSITORY_ERROR'); - expect(err.details.message).toBe('Repository error'); - }); -}); diff --git a/core/racing/application/use-cases/RankingUseCase.ts b/core/racing/application/use-cases/RankingUseCase.ts index ba6d64f2d..d90786a6a 100644 --- a/core/racing/application/use-cases/RankingUseCase.ts +++ b/core/racing/application/use-cases/RankingUseCase.ts @@ -7,6 +7,7 @@ import type { StandingRepository } from '../../domain/repositories/StandingRepository'; import type { DriverRepository } from '../../domain/repositories/DriverRepository'; +import type { DriverStatsRepository } from '../../domain/repositories/DriverStatsRepository'; import type { Logger } from '@core/shared/domain/Logger'; export interface DriverRanking { @@ -21,11 +22,25 @@ export class RankingUseCase { constructor( _standingRepository: StandingRepository, _driverRepository: DriverRepository, + private readonly _driverStatsRepository: DriverStatsRepository, private readonly _logger: Logger, ) {} async getAllDriverRankings(): Promise { this._logger.debug('Getting all driver rankings'); - return []; + const allStats = await this._driverStatsRepository.getAllStats(); + const rankings: DriverRanking[] = []; + + allStats.forEach((stats, driverId) => { + rankings.push({ + driverId, + rating: stats.rating, + wins: stats.wins, + totalRaces: stats.totalRaces, + overallRank: stats.overallRank, + }); + }); + + return rankings; } } \ No newline at end of file