website refactor

This commit is contained in:
2026-01-21 00:53:29 +01:00
parent 4516427a19
commit 5f3712e5ab
10 changed files with 85 additions and 201 deletions

View File

@@ -13,7 +13,7 @@ NEXT_TELEMETRY_DISABLED=1
# API (NestJS) # API (NestJS)
# ------------------------------------------ # ------------------------------------------
# API persistence is inferred from DATABASE_URL by default. # 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 DATABASE_URL=postgres://gridpilot_user:gridpilot_dev_pass@db:5432/gridpilot_dev

View File

@@ -101,6 +101,10 @@ export class SeedRacingData {
if (existingDrivers.length > 0 && !forceReseed) { if (existingDrivers.length > 0 && !forceReseed) {
this.logger.info('[Bootstrap] Racing seed skipped (drivers already exist), ensuring scoring configs'); this.logger.info('[Bootstrap] Racing seed skipped (drivers already exist), ensuring scoring configs');
await this.ensureScoringConfigsForExistingData(); 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; return;
} }

View File

@@ -31,33 +31,33 @@ export class DriverStatsOrmMapper {
const entityName = 'DriverStats'; const entityName = 'DriverStats';
assertNonEmptyString(entityName, 'driverId', entity.driverId); assertNonEmptyString(entityName, 'driverId', entity.driverId);
assertInteger(entityName, 'rating', entity.rating); assertInteger(entityName, 'rating', Number(entity.rating));
assertInteger(entityName, 'safetyRating', entity.safetyRating); assertInteger(entityName, 'safetyRating', Number(entity.safetyRating));
assertInteger(entityName, 'sportsmanshipRating', entity.sportsmanshipRating); assertNumber(entityName, 'sportsmanshipRating', Number(entity.sportsmanshipRating));
assertInteger(entityName, 'totalRaces', entity.totalRaces); assertInteger(entityName, 'totalRaces', Number(entity.totalRaces));
assertInteger(entityName, 'wins', entity.wins); assertInteger(entityName, 'wins', Number(entity.wins));
assertInteger(entityName, 'podiums', entity.podiums); assertInteger(entityName, 'podiums', Number(entity.podiums));
assertInteger(entityName, 'dnfs', entity.dnfs); assertInteger(entityName, 'dnfs', Number(entity.dnfs));
assertNumber(entityName, 'avgFinish', entity.avgFinish); assertNumber(entityName, 'avgFinish', Number(entity.avgFinish));
assertInteger(entityName, 'bestFinish', entity.bestFinish); assertInteger(entityName, 'bestFinish', Number(entity.bestFinish));
assertInteger(entityName, 'worstFinish', entity.worstFinish); assertInteger(entityName, 'worstFinish', Number(entity.worstFinish));
assertInteger(entityName, 'consistency', entity.consistency); assertInteger(entityName, 'consistency', Number(entity.consistency));
assertNonEmptyString(entityName, 'experienceLevel', entity.experienceLevel); assertNonEmptyString(entityName, 'experienceLevel', entity.experienceLevel);
const result: DriverStats = { const result: DriverStats = {
rating: entity.rating, rating: Number(entity.rating),
safetyRating: entity.safetyRating, safetyRating: Number(entity.safetyRating),
sportsmanshipRating: entity.sportsmanshipRating, sportsmanshipRating: Number(entity.sportsmanshipRating),
totalRaces: entity.totalRaces, totalRaces: Number(entity.totalRaces),
wins: entity.wins, wins: Number(entity.wins),
podiums: entity.podiums, podiums: Number(entity.podiums),
dnfs: entity.dnfs, dnfs: Number(entity.dnfs),
avgFinish: entity.avgFinish, avgFinish: Number(entity.avgFinish),
bestFinish: entity.bestFinish, bestFinish: Number(entity.bestFinish),
worstFinish: entity.worstFinish, worstFinish: Number(entity.worstFinish),
consistency: entity.consistency, consistency: Number(entity.consistency),
experienceLevel: entity.experienceLevel, experienceLevel: entity.experienceLevel,
overallRank: entity.overallRank ?? null, overallRank: entity.overallRank ? Number(entity.overallRank) : null,
}; };
return result; return result;

View File

@@ -32,8 +32,6 @@ import { InMemoryDriverExtendedProfileProvider } from '@adapters/racing/ports/In
import { InMemoryDriverRatingProvider } from '@adapters/racing/ports/InMemoryDriverRatingProvider'; import { InMemoryDriverRatingProvider } from '@adapters/racing/ports/InMemoryDriverRatingProvider';
// Import new use cases // Import new use cases
// Import new repositories // Import new repositories
import { InMemoryDriverStatsRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverStatsRepository';
import { InMemoryMediaRepository } from '@adapters/racing/persistence/media/InMemoryMediaRepository';
// Import MediaResolverAdapter // Import MediaResolverAdapter
import { MediaResolverAdapter } from '@adapters/media/MediaResolverAdapter'; import { MediaResolverAdapter } from '@adapters/media/MediaResolverAdapter';
// Import repository tokens // Import repository tokens
@@ -67,7 +65,6 @@ import {
IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN, IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN,
LIVERY_REPOSITORY_TOKEN, LIVERY_REPOSITORY_TOKEN,
LOGGER_TOKEN, LOGGER_TOKEN,
MEDIA_REPOSITORY_TOKEN,
MEDIA_RESOLVER_TOKEN, MEDIA_RESOLVER_TOKEN,
NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN, NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN,
RACE_REGISTRATION_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) // 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<string | null> => {
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, provide: RANKING_SERVICE_TOKEN,
useFactory: ( useFactory: (
standingRepo: StandingRepository, standingRepo: StandingRepository,
driverRepo: DriverRepository, driverRepo: DriverRepository,
driverStatsRepo: DriverStatsRepository,
logger: Logger logger: Logger
) => new RankingUseCase(standingRepo, driverRepo, logger), ) => new RankingUseCase(standingRepo, driverRepo, driverStatsRepo, logger),
inject: ['IStandingRepository', DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN], inject: ['IStandingRepository', DRIVER_REPOSITORY_TOKEN, DRIVER_STATS_REPOSITORY_TOKEN, LOGGER_TOKEN],
}, },
{ {
provide: DRIVER_STATS_SERVICE_TOKEN, provide: DRIVER_STATS_SERVICE_TOKEN,
useFactory: ( useFactory: (
resultRepo: ResultRepository, resultRepo: ResultRepository,
standingRepo: StandingRepository, standingRepo: StandingRepository,
driverStatsRepo: DriverStatsRepository,
logger: Logger logger: Logger
) => new DriverStatsUseCase(resultRepo, standingRepo, logger), ) => new DriverStatsUseCase(resultRepo, standingRepo, driverStatsRepo, logger),
inject: ['IResultRepository', 'IStandingRepository', LOGGER_TOKEN], inject: ['IResultRepository', 'IStandingRepository', DRIVER_STATS_REPOSITORY_TOKEN, LOGGER_TOKEN],
}, },
{ {
provide: DRIVER_RATING_PROVIDER_TOKEN, provide: DRIVER_RATING_PROVIDER_TOKEN,

View File

@@ -88,6 +88,27 @@ describe('Team domain (HTTP, module-wiring)', () => {
.expect(200); .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 () => { it('rejects unauthenticated actor on create team (401)', async () => {
await request(app.getHttpServer()) await request(app.getHttpServer())
.post('/teams') .post('/teams')

View File

@@ -19,6 +19,7 @@ import {
TEAM_STATS_REPOSITORY_TOKEN, TEAM_STATS_REPOSITORY_TOKEN,
UPDATE_TEAM_USE_CASE_TOKEN, UPDATE_TEAM_USE_CASE_TOKEN,
GET_TEAMS_LEADERBOARD_USE_CASE_TOKEN, GET_TEAMS_LEADERBOARD_USE_CASE_TOKEN,
DRIVER_STATS_REPOSITORY_TOKEN,
} from './TeamTokens'; } from './TeamTokens';
export { export {
@@ -165,11 +166,15 @@ export const TeamProviders: Provider[] = [
}, },
{ {
provide: GET_TEAMS_LEADERBOARD_USE_CASE_TOKEN, provide: GET_TEAMS_LEADERBOARD_USE_CASE_TOKEN,
useFactory: (teamRepo: TeamRepository, membershipRepo: TeamMembershipRepository, driverStatsRepo: DriverStatsRepository, logger: Logger) => useFactory: async (teamRepo: TeamRepository, membershipRepo: TeamMembershipRepository, driverStatsRepo: DriverStatsRepository, logger: Logger) => {
new GetTeamsLeaderboardUseCase(teamRepo, membershipRepo, (driverId) => { // Pre-fetch all driver stats for efficient lookup
const stats = driverStatsRepo.getDriverStatsSync?.(driverId); 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; return stats ? { rating: stats.rating, wins: stats.wins, totalRaces: stats.totalRaces } : null;
}, logger), }, logger);
inject: [TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, 'IDriverStatsRepository', LOGGER_TOKEN], },
inject: [TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_STATS_REPOSITORY_TOKEN, LOGGER_TOKEN],
}, },
]; ];

View File

@@ -7,6 +7,7 @@ export const TEAM_STATS_REPOSITORY_TOKEN = 'ITeamStatsRepository';
export const MEDIA_REPOSITORY_TOKEN = 'IMediaRepository'; export const MEDIA_REPOSITORY_TOKEN = 'IMediaRepository';
export const MEDIA_RESOLVER_TOKEN = 'MediaResolverPort'; export const MEDIA_RESOLVER_TOKEN = 'MediaResolverPort';
export const RESULT_REPOSITORY_TOKEN = 'IResultRepository'; 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_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'); export const GET_TEAM_DETAILS_USE_CASE_TOKEN = Symbol('GET_TEAM_DETAILS_USE_CASE_TOKEN');

View File

@@ -7,6 +7,7 @@
import type { ResultRepository } from '../../domain/repositories/ResultRepository'; import type { ResultRepository } from '../../domain/repositories/ResultRepository';
import type { StandingRepository } from '../../domain/repositories/StandingRepository'; import type { StandingRepository } from '../../domain/repositories/StandingRepository';
import type { DriverStatsRepository } from '../../domain/repositories/DriverStatsRepository';
import type { Logger } from '@core/shared/domain/Logger'; import type { Logger } from '@core/shared/domain/Logger';
export interface DriverStats { export interface DriverStats {
@@ -29,11 +30,12 @@ export class DriverStatsUseCase {
constructor( constructor(
_resultRepository: ResultRepository, _resultRepository: ResultRepository,
_standingRepository: StandingRepository, _standingRepository: StandingRepository,
private readonly _driverStatsRepository: DriverStatsRepository,
private readonly _logger: Logger, private readonly _logger: Logger,
) {} ) {}
async getDriverStats(driverId: string): Promise<DriverStats | null> { async getDriverStats(driverId: string): Promise<DriverStats | null> {
this._logger.debug(`Getting stats for driver ${driverId}`); this._logger.debug(`Getting stats for driver ${driverId}`);
return null; return this._driverStatsRepository.getDriverStats(driverId);
} }
} }

View File

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

View File

@@ -7,6 +7,7 @@
import type { StandingRepository } from '../../domain/repositories/StandingRepository'; import type { StandingRepository } from '../../domain/repositories/StandingRepository';
import type { DriverRepository } from '../../domain/repositories/DriverRepository'; import type { DriverRepository } from '../../domain/repositories/DriverRepository';
import type { DriverStatsRepository } from '../../domain/repositories/DriverStatsRepository';
import type { Logger } from '@core/shared/domain/Logger'; import type { Logger } from '@core/shared/domain/Logger';
export interface DriverRanking { export interface DriverRanking {
@@ -21,11 +22,25 @@ export class RankingUseCase {
constructor( constructor(
_standingRepository: StandingRepository, _standingRepository: StandingRepository,
_driverRepository: DriverRepository, _driverRepository: DriverRepository,
private readonly _driverStatsRepository: DriverStatsRepository,
private readonly _logger: Logger, private readonly _logger: Logger,
) {} ) {}
async getAllDriverRankings(): Promise<DriverRanking[]> { async getAllDriverRankings(): Promise<DriverRanking[]> {
this._logger.debug('Getting all driver rankings'); 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;
} }
} }