team rating
This commit is contained in:
@@ -21,10 +21,17 @@ import type { IProtestRepository } from '@core/racing/domain/repositories/IProte
|
||||
import type { IPenaltyRepository } from '@core/racing/domain/repositories/IPenaltyRepository';
|
||||
import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository';
|
||||
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
|
||||
import type { IDriverStatsRepository } from '@core/racing/domain/repositories/IDriverStatsRepository';
|
||||
import type { ITeamStatsRepository } from '@core/racing/domain/repositories/ITeamStatsRepository';
|
||||
import type { IMediaRepository } from '@core/racing/domain/repositories/IMediaRepository';
|
||||
import { createRacingSeed } from './racing/RacingSeed';
|
||||
import { seedId } from './racing/SeedIdHelper';
|
||||
import { DriverStatsStore } from '@adapters/racing/services/DriverStatsStore';
|
||||
import { TeamStatsStore } from '@adapters/racing/services/TeamStatsStore';
|
||||
import { Driver } from '@core/racing/domain/entities/Driver';
|
||||
import { Result } from '@core/racing/domain/entities/result/Result';
|
||||
import { Standing } from '@core/racing/domain/entities/Standing';
|
||||
import { Team } from '@core/racing/domain/entities/Team';
|
||||
import type { DriverStats } from '@core/racing/application/use-cases/IDriverStatsUseCase';
|
||||
import type { TeamStats } from '@core/racing/domain/repositories/ITeamStatsRepository';
|
||||
|
||||
export type RacingSeedDependencies = {
|
||||
driverRepository: IDriverRepository;
|
||||
@@ -47,6 +54,9 @@ export type RacingSeedDependencies = {
|
||||
sponsorRepository: ISponsorRepository;
|
||||
feedRepository: IFeedRepository;
|
||||
socialGraphRepository: ISocialGraphRepository;
|
||||
driverStatsRepository: IDriverStatsRepository;
|
||||
teamStatsRepository: ITeamStatsRepository;
|
||||
mediaRepository: IMediaRepository;
|
||||
};
|
||||
|
||||
export class SeedRacingData {
|
||||
@@ -92,19 +102,12 @@ export class SeedRacingData {
|
||||
driverCount: 150 // Expanded from 100 to 150
|
||||
});
|
||||
|
||||
// Populate the driver stats store for the InMemoryDriverStatsService
|
||||
const driverStatsStore = DriverStatsStore.getInstance();
|
||||
driverStatsStore.clear(); // Clear any existing stats
|
||||
driverStatsStore.loadStats(seed.driverStats);
|
||||
// Clear existing stats repositories
|
||||
await this.seedDeps.driverStatsRepository.clear();
|
||||
await this.seedDeps.teamStatsRepository.clear();
|
||||
await this.seedDeps.mediaRepository.clear();
|
||||
|
||||
this.logger.info(`[Bootstrap] Loaded driver stats for ${seed.driverStats.size} drivers`);
|
||||
|
||||
// Populate the team stats store for the AllTeamsPresenter
|
||||
const teamStatsStore = TeamStatsStore.getInstance();
|
||||
teamStatsStore.clear(); // Clear any existing stats
|
||||
teamStatsStore.loadStats(seed.teamStats);
|
||||
|
||||
this.logger.info(`[Bootstrap] Loaded team stats for ${seed.teamStats.size} teams`);
|
||||
this.logger.info('[Bootstrap] Cleared existing stats and media repositories');
|
||||
|
||||
let sponsorshipRequestsSeededViaRepo = false;
|
||||
const seedableSponsorshipRequests = this.seedDeps
|
||||
@@ -304,11 +307,236 @@ export class SeedRacingData {
|
||||
});
|
||||
}
|
||||
|
||||
// Compute and store driver stats from real data
|
||||
await this.computeAndStoreDriverStats();
|
||||
|
||||
// Compute and store team stats from real data
|
||||
await this.computeAndStoreTeamStats();
|
||||
|
||||
// Seed media assets (logos, images)
|
||||
await this.seedMediaAssets(seed);
|
||||
|
||||
this.logger.info(
|
||||
`[Bootstrap] Seeded racing data: drivers=${seed.drivers.length}, leagues=${seed.leagues.length}, races=${seed.races.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
private async computeAndStoreDriverStats(): Promise<void> {
|
||||
const drivers = await this.seedDeps.driverRepository.findAll();
|
||||
const standings = await this.seedDeps.standingRepository.findAll();
|
||||
const results = await this.seedDeps.resultRepository.findAll();
|
||||
|
||||
this.logger.info(`[Bootstrap] Computing stats for ${drivers.length} drivers from ${standings.length} standings and ${results.length} results`);
|
||||
|
||||
for (const driver of drivers) {
|
||||
const driverResults = results.filter(r => r.driverId.toString() === driver.id);
|
||||
const driverStandings = standings.filter(s => s.driverId.toString() === driver.id);
|
||||
|
||||
if (driverResults.length === 0) continue;
|
||||
|
||||
const stats = this.calculateDriverStats(driver, driverResults, driverStandings);
|
||||
await this.seedDeps.driverStatsRepository.saveDriverStats(driver.id, stats);
|
||||
}
|
||||
|
||||
this.logger.info(`[Bootstrap] Computed and stored stats for ${drivers.length} drivers`);
|
||||
}
|
||||
|
||||
private calculateDriverStats(driver: Driver, results: Result[], standings: Standing[]): DriverStats {
|
||||
const wins = results.filter(r => r.position.toNumber() === 1).length;
|
||||
const podiums = results.filter(r => r.position.toNumber() <= 3).length;
|
||||
const dnfs = results.filter(r => r.position.toNumber() > 20).length;
|
||||
const totalRaces = results.length;
|
||||
|
||||
const positions = results.map(r => r.position.toNumber());
|
||||
const avgFinish = positions.reduce((sum, pos) => sum + pos, 0) / totalRaces;
|
||||
const bestFinish = Math.min(...positions);
|
||||
const worstFinish = Math.max(...positions);
|
||||
|
||||
// Calculate rating based on performance
|
||||
let rating = 1000;
|
||||
const driverStanding = standings.find(s => s.driverId.toString() === driver.id);
|
||||
if (driverStanding) {
|
||||
const pointsBonus = driverStanding.points.toNumber() * 2;
|
||||
const positionBonus = Math.max(0, 50 - (driverStanding.position.toNumber() * 2));
|
||||
const winBonus = driverStanding.wins * 100;
|
||||
rating = Math.round(1000 + pointsBonus + positionBonus + winBonus);
|
||||
} else {
|
||||
const performanceBonus = ((totalRaces - wins) * 5) + ((totalRaces - podiums) * 2);
|
||||
rating = Math.round(1000 + (wins * 100) + (podiums * 50) - performanceBonus);
|
||||
}
|
||||
|
||||
// Calculate consistency
|
||||
const avgPosition = avgFinish;
|
||||
const variance = positions.reduce((sum, pos) => sum + Math.pow(pos - avgPosition, 2), 0) / totalRaces;
|
||||
const consistency = Math.round(Math.max(0, 100 - (variance * 2)));
|
||||
|
||||
// Safety rating (based on incidents)
|
||||
const totalIncidents = results.reduce((sum, r) => sum + r.incidents.toNumber(), 0);
|
||||
const safetyRating = Math.round(Math.max(0, 100 - (totalIncidents / totalRaces)));
|
||||
|
||||
// Sportsmanship rating (placeholder)
|
||||
const sportsmanshipRating = 4.5;
|
||||
|
||||
// Experience level
|
||||
const experienceLevel = this.determineExperienceLevel(totalRaces);
|
||||
|
||||
// Overall rank
|
||||
const overallRank = driverStanding ? driverStanding.position.toNumber() : null;
|
||||
|
||||
return {
|
||||
rating,
|
||||
safetyRating,
|
||||
sportsmanshipRating,
|
||||
totalRaces,
|
||||
wins,
|
||||
podiums,
|
||||
dnfs,
|
||||
avgFinish: Math.round(avgFinish * 10) / 10,
|
||||
bestFinish,
|
||||
worstFinish,
|
||||
consistency,
|
||||
experienceLevel,
|
||||
overallRank
|
||||
};
|
||||
}
|
||||
|
||||
private async computeAndStoreTeamStats(): Promise<void> {
|
||||
const teams = await this.seedDeps.teamRepository.findAll();
|
||||
const results = await this.seedDeps.resultRepository.findAll();
|
||||
const drivers = await this.seedDeps.driverRepository.findAll();
|
||||
|
||||
this.logger.info(`[Bootstrap] Computing stats for ${teams.length} teams`);
|
||||
|
||||
for (const team of teams) {
|
||||
// Get team members using the correct method
|
||||
const teamMemberships = await this.seedDeps.teamMembershipRepository.getTeamMembers(team.id);
|
||||
const teamMemberIds = teamMemberships.map(m => m.driverId.toString());
|
||||
|
||||
// Get results for team members
|
||||
const teamResults = results.filter(r => teamMemberIds.includes(r.driverId.toString()));
|
||||
|
||||
// Get team drivers for name resolution
|
||||
const teamDrivers = drivers.filter(d => teamMemberIds.includes(d.id));
|
||||
|
||||
const stats = this.calculateTeamStats(team, teamResults, teamDrivers);
|
||||
await this.seedDeps.teamStatsRepository.saveTeamStats(team.id, stats);
|
||||
}
|
||||
|
||||
this.logger.info(`[Bootstrap] Computed and stored stats for ${teams.length} teams`);
|
||||
}
|
||||
|
||||
private calculateTeamStats(team: Team, results: Result[], drivers: Driver[]): TeamStats {
|
||||
const wins = results.filter(r => r.position.toNumber() === 1).length;
|
||||
const totalRaces = results.length;
|
||||
|
||||
// Calculate rating
|
||||
const baseRating = 1000;
|
||||
const winBonus = wins * 50;
|
||||
const raceBonus = Math.min(totalRaces * 5, 200);
|
||||
const rating = Math.round(baseRating + winBonus + raceBonus);
|
||||
|
||||
// Determine performance level
|
||||
let performanceLevel: 'beginner' | 'intermediate' | 'advanced' | 'pro';
|
||||
if (wins >= 20) performanceLevel = 'pro';
|
||||
else if (wins >= 10) performanceLevel = 'advanced';
|
||||
else if (wins >= 5) performanceLevel = 'intermediate';
|
||||
else performanceLevel = 'beginner';
|
||||
|
||||
// Determine specialization (based on race types - simplified)
|
||||
const specialization: 'endurance' | 'sprint' | 'mixed' = 'mixed';
|
||||
|
||||
// Get region from team name or first driver
|
||||
const region = drivers.length > 0 && drivers[0] ? drivers[0].country.toString() : 'International';
|
||||
|
||||
// Languages (based on drivers)
|
||||
const languages = Array.from(new Set(drivers.map(d => {
|
||||
// Simplified language mapping based on country
|
||||
const country = d.country.toString().toLowerCase();
|
||||
if (country === 'us' || country === 'gb' || country === 'ca') return 'en';
|
||||
if (country === 'de') return 'de';
|
||||
if (country === 'fr') return 'fr';
|
||||
if (country === 'es') return 'es';
|
||||
if (country === 'it') return 'it';
|
||||
if (country === 'jp') return 'ja';
|
||||
return 'en';
|
||||
})));
|
||||
|
||||
return {
|
||||
logoUrl: `https://api.gridpilot.io/media/team/${team.id}/logo.png`,
|
||||
performanceLevel,
|
||||
specialization,
|
||||
region,
|
||||
languages,
|
||||
totalWins: wins,
|
||||
totalRaces,
|
||||
rating
|
||||
};
|
||||
}
|
||||
|
||||
private determineExperienceLevel(totalRaces: number): string {
|
||||
if (totalRaces >= 100) return 'Veteran';
|
||||
if (totalRaces >= 50) return 'Experienced';
|
||||
if (totalRaces >= 20) return 'Intermediate';
|
||||
if (totalRaces >= 10) return 'Rookie';
|
||||
return 'Beginner';
|
||||
}
|
||||
|
||||
private async seedMediaAssets(seed: any): Promise<void> {
|
||||
// Seed driver avatars
|
||||
for (const driver of seed.drivers) {
|
||||
const avatarUrl = `https://api.gridpilot.io/media/driver/${driver.id}/avatar.png`;
|
||||
|
||||
// Type assertion to access the helper method
|
||||
const mediaRepo = this.seedDeps.mediaRepository as any;
|
||||
if (mediaRepo.setDriverAvatar) {
|
||||
mediaRepo.setDriverAvatar(driver.id, avatarUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Seed team logos
|
||||
for (const team of seed.teams) {
|
||||
const logoUrl = `https://api.gridpilot.io/media/team/${team.id}/logo.png`;
|
||||
|
||||
const mediaRepo = this.seedDeps.mediaRepository as any;
|
||||
if (mediaRepo.setTeamLogo) {
|
||||
mediaRepo.setTeamLogo(team.id, logoUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Seed track images
|
||||
for (const track of seed.tracks || []) {
|
||||
const trackImageUrl = `https://api.gridpilot.io/media/track/${track.id}/image.png`;
|
||||
|
||||
const mediaRepo = this.seedDeps.mediaRepository as any;
|
||||
if (mediaRepo.setTrackImage) {
|
||||
mediaRepo.setTrackImage(track.id, trackImageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Seed category icons (if categories exist)
|
||||
const categories = ['beginner', 'intermediate', 'advanced', 'pro', 'endurance', 'sprint'];
|
||||
for (const category of categories) {
|
||||
const iconUrl = `https://api.gridpilot.io/media/category/${category}/icon.png`;
|
||||
|
||||
const mediaRepo = this.seedDeps.mediaRepository as any;
|
||||
if (mediaRepo.setCategoryIcon) {
|
||||
mediaRepo.setCategoryIcon(category, iconUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Seed sponsor logos
|
||||
for (const sponsor of seed.sponsors || []) {
|
||||
const logoUrl = `https://api.gridpilot.io/media/sponsor/${sponsor.id}/logo.png`;
|
||||
|
||||
const mediaRepo = this.seedDeps.mediaRepository as any;
|
||||
if (mediaRepo.setSponsorLogo) {
|
||||
mediaRepo.setSponsorLogo(sponsor.id, logoUrl);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.info(`[Bootstrap] Seeded media assets for ${seed.drivers.length} drivers, ${seed.teams.length} teams`);
|
||||
}
|
||||
|
||||
private async clearExistingRacingData(): Promise<void> {
|
||||
// Get all existing drivers
|
||||
const drivers = await this.seedDeps.driverRepository.findAll();
|
||||
|
||||
@@ -19,39 +19,35 @@ export class RacingRaceFactory {
|
||||
const races: Race[] = [];
|
||||
|
||||
// Create races with systematic coverage of different statuses and scenarios
|
||||
const statuses: Array<'scheduled' | 'running' | 'completed' | 'cancelled'> = ['scheduled', 'running', 'completed', 'cancelled'];
|
||||
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
for (let i = 1; i <= 500; i++) {
|
||||
const leagueId = leagueIds[(i - 1) % leagueIds.length] ?? demoLeagueId;
|
||||
const trackId = trackIds[(i - 1) % trackIds.length]!;
|
||||
const track = tracks.find(t => t.id === trackId)!;
|
||||
|
||||
// Determine status systematically to ensure coverage
|
||||
// Determine status systematically to ensure good coverage
|
||||
let status: 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
let scheduledAt: Date;
|
||||
|
||||
if (i <= 4) {
|
||||
// First 4 races: one of each status
|
||||
status = statuses[i - 1]!;
|
||||
scheduledAt = this.addDays(this.baseDate, i <= 2 ? -35 + i : 1 + (i - 2) * 2);
|
||||
} else if (i <= 10) {
|
||||
// Next 6: completed races
|
||||
status = 'completed';
|
||||
scheduledAt = this.addDays(this.baseDate, -35 + i);
|
||||
} else if (i <= 15) {
|
||||
// Next 5: scheduled future races
|
||||
status = 'scheduled';
|
||||
scheduledAt = this.addDays(this.baseDate, 1 + (i - 10) * 3);
|
||||
} else if (i <= 20) {
|
||||
// Next 5: cancelled races
|
||||
// Use modulo to create a balanced distribution across 500 races
|
||||
const statusMod = i % 20; // 20 different patterns
|
||||
|
||||
if (statusMod === 1 || statusMod === 2 || statusMod === 3) {
|
||||
// 15% running (3 out of 20)
|
||||
status = 'running';
|
||||
scheduledAt = this.addDays(this.baseDate, -1 + (statusMod * 0.5)); // Recent past/current
|
||||
} else if (statusMod === 4 || statusMod === 5 || statusMod === 6 || statusMod === 7) {
|
||||
// 20% cancelled (4 out of 20)
|
||||
status = 'cancelled';
|
||||
scheduledAt = this.addDays(this.baseDate, -20 + (i - 15));
|
||||
scheduledAt = this.addDays(this.baseDate, -30 + (statusMod * 2));
|
||||
} else if (statusMod === 8 || statusMod === 9 || statusMod === 10 || statusMod === 11 || statusMod === 12) {
|
||||
// 25% completed (5 out of 20)
|
||||
status = 'completed';
|
||||
scheduledAt = this.addDays(this.baseDate, -50 + (statusMod * 3));
|
||||
} else {
|
||||
// Rest: mix of scheduled and completed
|
||||
status = i % 3 === 0 ? 'completed' : 'scheduled';
|
||||
scheduledAt = status === 'completed'
|
||||
? this.addDays(this.baseDate, -10 + (i - 20))
|
||||
: this.addDays(this.baseDate, 5 + (i - 20) * 2);
|
||||
// 40% scheduled (8 out of 20)
|
||||
status = 'scheduled';
|
||||
scheduledAt = this.addDays(this.baseDate, 1 + ((statusMod - 13) * 2));
|
||||
}
|
||||
|
||||
const base = {
|
||||
@@ -63,58 +59,48 @@ export class RacingRaceFactory {
|
||||
car: cars[(i - 1) % cars.length]!,
|
||||
};
|
||||
|
||||
// Special case for running race
|
||||
// Create race based on status with appropriate data
|
||||
if (status === 'running') {
|
||||
races.push(
|
||||
Race.create({
|
||||
...base,
|
||||
status: 'running',
|
||||
strengthOfField: 45 + (i % 50), // Valid SOF: 0-100
|
||||
registeredCount: 12 + (i % 5), // Varying registration counts
|
||||
maxParticipants: 24, // Ensure max is set
|
||||
strengthOfField: 40 + (i % 60), // Valid SOF: 0-100
|
||||
registeredCount: 10 + (i % 15), // Varying registration counts
|
||||
maxParticipants: 20 + (i % 8), // 20-28 participants
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add varying SOF and registration counts for completed races
|
||||
if (status === 'completed') {
|
||||
} else if (status === 'completed') {
|
||||
races.push(
|
||||
Race.create({
|
||||
...base,
|
||||
status: 'completed',
|
||||
strengthOfField: 35 + (i % 60), // Valid SOF: 0-100
|
||||
registeredCount: 8 + (i % 8),
|
||||
maxParticipants: 20, // Ensure max is set
|
||||
strengthOfField: 30 + (i % 70), // Valid SOF: 0-100
|
||||
registeredCount: 6 + (i % 12),
|
||||
maxParticipants: 16 + (i % 10),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scheduled races with some having registration data
|
||||
if (status === 'scheduled') {
|
||||
const hasRegistrations = i % 4 !== 0; // 75% have registrations
|
||||
} else if (status === 'scheduled') {
|
||||
const hasRegistrations = i % 3 !== 0; // 66% have registrations
|
||||
races.push(
|
||||
Race.create({
|
||||
...base,
|
||||
status: 'scheduled',
|
||||
...(hasRegistrations && {
|
||||
strengthOfField: 40 + (i % 55), // Valid SOF: 0-100
|
||||
registeredCount: 5 + (i % 10),
|
||||
maxParticipants: 16 + (i % 10), // Ensure max is set and reasonable
|
||||
strengthOfField: 35 + (i % 65), // Valid SOF: 0-100
|
||||
registeredCount: 4 + (i % 12),
|
||||
maxParticipants: 14 + (i % 12),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
} else if (status === 'cancelled') {
|
||||
races.push(
|
||||
Race.create({
|
||||
...base,
|
||||
status: 'cancelled',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Cancelled races
|
||||
races.push(
|
||||
Race.create({
|
||||
...base,
|
||||
status: 'cancelled',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return races;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryDriverStatsRepository
|
||||
*
|
||||
* In-memory implementation of IDriverStatsRepository.
|
||||
* Stores computed driver statistics for caching and frontend queries.
|
||||
*/
|
||||
|
||||
import type { IDriverStatsRepository } from '@core/racing/domain/repositories/IDriverStatsRepository';
|
||||
import type { DriverStats } from '@core/racing/application/use-cases/IDriverStatsUseCase';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
|
||||
export class InMemoryDriverStatsRepository implements IDriverStatsRepository {
|
||||
private stats = new Map<string, DriverStats>();
|
||||
|
||||
constructor(private readonly logger: Logger) {
|
||||
this.logger.info('[InMemoryDriverStatsRepository] Initialized.');
|
||||
}
|
||||
|
||||
async getDriverStats(driverId: string): Promise<DriverStats | null> {
|
||||
this.logger.debug(`[InMemoryDriverStatsRepository] Getting stats for driver: ${driverId}`);
|
||||
return this.stats.get(driverId) ?? null;
|
||||
}
|
||||
|
||||
getDriverStatsSync(driverId: string): DriverStats | null {
|
||||
this.logger.debug(`[InMemoryDriverStatsRepository] Getting stats (sync) for driver: ${driverId}`);
|
||||
return this.stats.get(driverId) ?? null;
|
||||
}
|
||||
|
||||
async saveDriverStats(driverId: string, stats: DriverStats): Promise<void> {
|
||||
this.logger.debug(`[InMemoryDriverStatsRepository] Saving stats for driver: ${driverId}`);
|
||||
this.stats.set(driverId, stats);
|
||||
}
|
||||
|
||||
async getAllStats(): Promise<Map<string, DriverStats>> {
|
||||
this.logger.debug('[InMemoryDriverStatsRepository] Getting all stats');
|
||||
return new Map(this.stats);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.logger.info('[InMemoryDriverStatsRepository] Clearing all stats');
|
||||
this.stats.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryTeamStatsRepository
|
||||
*
|
||||
* In-memory implementation of ITeamStatsRepository.
|
||||
* Stores computed team statistics for caching and frontend queries.
|
||||
*/
|
||||
|
||||
import type { ITeamStatsRepository, TeamStats } from '@core/racing/domain/repositories/ITeamStatsRepository';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
|
||||
export class InMemoryTeamStatsRepository implements ITeamStatsRepository {
|
||||
private stats = new Map<string, TeamStats>();
|
||||
|
||||
constructor(private readonly logger: Logger) {
|
||||
this.logger.info('[InMemoryTeamStatsRepository] Initialized.');
|
||||
}
|
||||
|
||||
async getTeamStats(teamId: string): Promise<TeamStats | null> {
|
||||
this.logger.debug(`[InMemoryTeamStatsRepository] Getting stats for team: ${teamId}`);
|
||||
return this.stats.get(teamId) ?? null;
|
||||
}
|
||||
|
||||
getTeamStatsSync(teamId: string): TeamStats | null {
|
||||
this.logger.debug(`[InMemoryTeamStatsRepository] Getting stats (sync) for team: ${teamId}`);
|
||||
return this.stats.get(teamId) ?? null;
|
||||
}
|
||||
|
||||
async saveTeamStats(teamId: string, stats: TeamStats): Promise<void> {
|
||||
this.logger.debug(`[InMemoryTeamStatsRepository] Saving stats for team: ${teamId}`);
|
||||
this.stats.set(teamId, stats);
|
||||
}
|
||||
|
||||
async getAllStats(): Promise<Map<string, TeamStats>> {
|
||||
this.logger.debug('[InMemoryTeamStatsRepository] Getting all stats');
|
||||
return new Map(this.stats);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.logger.info('[InMemoryTeamStatsRepository] Clearing all stats');
|
||||
this.stats.clear();
|
||||
}
|
||||
}
|
||||
70
adapters/racing/persistence/media/InMemoryMediaRepository.ts
Normal file
70
adapters/racing/persistence/media/InMemoryMediaRepository.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryMediaRepository
|
||||
*
|
||||
* In-memory implementation of IMediaRepository.
|
||||
* Stores URLs for static media assets like logos and images.
|
||||
*/
|
||||
|
||||
import type { IMediaRepository } from '@core/racing/domain/repositories/IMediaRepository';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
|
||||
export class InMemoryMediaRepository implements IMediaRepository {
|
||||
private driverAvatars = new Map<string, string>();
|
||||
private teamLogos = new Map<string, string>();
|
||||
private trackImages = new Map<string, string>();
|
||||
private categoryIcons = new Map<string, string>();
|
||||
private sponsorLogos = new Map<string, string>();
|
||||
|
||||
constructor(private readonly logger: Logger) {
|
||||
this.logger.info('[InMemoryMediaRepository] Initialized.');
|
||||
}
|
||||
|
||||
async getDriverAvatar(driverId: string): Promise<string | null> {
|
||||
return this.driverAvatars.get(driverId) ?? null;
|
||||
}
|
||||
|
||||
async getTeamLogo(teamId: string): Promise<string | null> {
|
||||
return this.teamLogos.get(teamId) ?? null;
|
||||
}
|
||||
|
||||
async getTrackImage(trackId: string): Promise<string | null> {
|
||||
return this.trackImages.get(trackId) ?? null;
|
||||
}
|
||||
|
||||
async getCategoryIcon(categoryId: string): Promise<string | null> {
|
||||
return this.categoryIcons.get(categoryId) ?? null;
|
||||
}
|
||||
|
||||
async getSponsorLogo(sponsorId: string): Promise<string | null> {
|
||||
return this.sponsorLogos.get(sponsorId) ?? null;
|
||||
}
|
||||
|
||||
// Helper methods for seeding
|
||||
setDriverAvatar(driverId: string, url: string): void {
|
||||
this.driverAvatars.set(driverId, url);
|
||||
}
|
||||
|
||||
setTeamLogo(teamId: string, url: string): void {
|
||||
this.teamLogos.set(teamId, url);
|
||||
}
|
||||
|
||||
setTrackImage(trackId: string, url: string): void {
|
||||
this.trackImages.set(trackId, url);
|
||||
}
|
||||
|
||||
setCategoryIcon(categoryId: string, url: string): void {
|
||||
this.categoryIcons.set(categoryId, url);
|
||||
}
|
||||
|
||||
setSponsorLogo(sponsorId: string, url: string): void {
|
||||
this.sponsorLogos.set(sponsorId, url);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.driverAvatars.clear();
|
||||
this.teamLogos.clear();
|
||||
this.trackImages.clear();
|
||||
this.categoryIcons.clear();
|
||||
this.sponsorLogos.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* ORM Entity: TeamRatingEvent
|
||||
*
|
||||
* Stores team rating events in the ledger with indexes for efficient querying
|
||||
* by teamId and ordering by occurredAt for snapshot computation.
|
||||
*/
|
||||
@Entity({ name: 'team_rating_events' })
|
||||
@Index(['teamId', 'occurredAt', 'createdAt', 'id'], { unique: true })
|
||||
export class TeamRatingEventOrmEntity {
|
||||
@PrimaryColumn({ type: 'text' })
|
||||
id!: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'text' })
|
||||
teamId!: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'text' })
|
||||
dimension!: string;
|
||||
|
||||
@Column({ type: 'double precision' })
|
||||
delta!: number;
|
||||
|
||||
@Column({ type: 'double precision', nullable: true })
|
||||
weight?: number;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'timestamptz' })
|
||||
occurredAt!: Date;
|
||||
|
||||
@Column({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
source!: {
|
||||
type: 'race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment';
|
||||
id?: string;
|
||||
};
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
reason!: {
|
||||
code: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
visibility!: {
|
||||
public: boolean;
|
||||
};
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
version!: number;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* ORM Entity: TeamRating
|
||||
*
|
||||
* Stores the current rating snapshot per team.
|
||||
* Uses JSONB for dimension data to keep schema flexible.
|
||||
*/
|
||||
@Entity({ name: 'team_ratings' })
|
||||
@Index(['teamId'], { unique: true })
|
||||
export class TeamRatingOrmEntity {
|
||||
@PrimaryColumn({ type: 'text' })
|
||||
teamId!: string;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
driving!: {
|
||||
value: number;
|
||||
confidence: number;
|
||||
sampleSize: number;
|
||||
trend: 'rising' | 'stable' | 'falling';
|
||||
lastUpdated: Date;
|
||||
};
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
adminTrust!: {
|
||||
value: number;
|
||||
confidence: number;
|
||||
sampleSize: number;
|
||||
trend: 'rising' | 'stable' | 'falling';
|
||||
lastUpdated: Date;
|
||||
};
|
||||
|
||||
@Column({ type: 'double precision' })
|
||||
overall!: number;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
calculatorVersion?: string;
|
||||
|
||||
@Column({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@Column({ type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { TeamRatingEventOrmMapper } from './TeamRatingEventOrmMapper';
|
||||
import { TeamRatingEventOrmEntity } from '../entities/TeamRatingEventOrmEntity';
|
||||
import { TeamRatingEvent } from '@core/racing/domain/entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '@core/racing/domain/value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '@core/racing/domain/value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '@core/racing/domain/value-objects/TeamRatingDelta';
|
||||
|
||||
describe('TeamRatingEventOrmMapper', () => {
|
||||
const validEntityProps = {
|
||||
id: '123e4567-e89b-12d3-a456-426614174000',
|
||||
teamId: 'team-123',
|
||||
dimension: 'driving',
|
||||
delta: 10,
|
||||
weight: 1,
|
||||
occurredAt: new Date('2024-01-01T00:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
source: { type: 'race' as const, id: 'race-456' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st in race' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const validDomainProps = {
|
||||
id: TeamRatingEventId.create('123e4567-e89b-12d3-a456-426614174000'),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
weight: 1,
|
||||
occurredAt: new Date('2024-01-01T00:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
source: { type: 'race' as const, id: 'race-456' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st in race' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
};
|
||||
|
||||
describe('toDomain', () => {
|
||||
it('should convert ORM entity to domain entity', () => {
|
||||
const entity = Object.assign(new TeamRatingEventOrmEntity(), validEntityProps);
|
||||
const domain = TeamRatingEventOrmMapper.toDomain(entity);
|
||||
|
||||
expect(domain.id.value).toBe(validEntityProps.id);
|
||||
expect(domain.teamId).toBe(validEntityProps.teamId);
|
||||
expect(domain.dimension.value).toBe(validEntityProps.dimension);
|
||||
expect(domain.delta.value).toBe(validEntityProps.delta);
|
||||
expect(domain.weight).toBe(validEntityProps.weight);
|
||||
expect(domain.occurredAt).toEqual(validEntityProps.occurredAt);
|
||||
expect(domain.createdAt).toEqual(validEntityProps.createdAt);
|
||||
expect(domain.source).toEqual(validEntityProps.source);
|
||||
expect(domain.reason).toEqual(validEntityProps.reason);
|
||||
expect(domain.visibility).toEqual(validEntityProps.visibility);
|
||||
expect(domain.version).toBe(validEntityProps.version);
|
||||
});
|
||||
|
||||
it('should handle optional weight', () => {
|
||||
const entity = Object.assign(new TeamRatingEventOrmEntity(), {
|
||||
...validEntityProps,
|
||||
weight: undefined,
|
||||
});
|
||||
const domain = TeamRatingEventOrmMapper.toDomain(entity);
|
||||
|
||||
expect(domain.weight).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle null weight', () => {
|
||||
const entity = Object.assign(new TeamRatingEventOrmEntity(), {
|
||||
...validEntityProps,
|
||||
weight: null,
|
||||
});
|
||||
const domain = TeamRatingEventOrmMapper.toDomain(entity);
|
||||
|
||||
expect(domain.weight).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toOrmEntity', () => {
|
||||
it('should convert domain entity to ORM entity', () => {
|
||||
const domain = TeamRatingEvent.create(validDomainProps);
|
||||
const entity = TeamRatingEventOrmMapper.toOrmEntity(domain);
|
||||
|
||||
expect(entity.id).toBe(validDomainProps.id.value);
|
||||
expect(entity.teamId).toBe(validDomainProps.teamId);
|
||||
expect(entity.dimension).toBe(validDomainProps.dimension.value);
|
||||
expect(entity.delta).toBe(validDomainProps.delta.value);
|
||||
expect(entity.weight).toBe(validDomainProps.weight);
|
||||
expect(entity.occurredAt).toEqual(validDomainProps.occurredAt);
|
||||
expect(entity.createdAt).toEqual(validDomainProps.createdAt);
|
||||
expect(entity.source).toEqual(validDomainProps.source);
|
||||
expect(entity.reason).toEqual(validDomainProps.reason);
|
||||
expect(entity.visibility).toEqual(validDomainProps.visibility);
|
||||
expect(entity.version).toBe(validDomainProps.version);
|
||||
});
|
||||
|
||||
it('should handle domain entity without weight', () => {
|
||||
const props = { ...validDomainProps };
|
||||
delete (props as any).weight;
|
||||
const domain = TeamRatingEvent.create(props);
|
||||
const entity = TeamRatingEventOrmMapper.toOrmEntity(domain);
|
||||
|
||||
expect(entity.weight).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { TeamRatingEvent } from '@core/racing/domain/entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '@core/racing/domain/value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '@core/racing/domain/value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '@core/racing/domain/value-objects/TeamRatingDelta';
|
||||
import { TeamRatingEventOrmEntity } from '../entities/TeamRatingEventOrmEntity';
|
||||
|
||||
/**
|
||||
* Mapper: TeamRatingEventOrmMapper
|
||||
*
|
||||
* Converts between TeamRatingEvent domain entity and TeamRatingEventOrmEntity.
|
||||
*/
|
||||
export class TeamRatingEventOrmMapper {
|
||||
/**
|
||||
* Convert ORM entity to domain entity
|
||||
*/
|
||||
static toDomain(entity: TeamRatingEventOrmEntity): TeamRatingEvent {
|
||||
const props: any = {
|
||||
id: TeamRatingEventId.create(entity.id),
|
||||
teamId: entity.teamId,
|
||||
dimension: TeamRatingDimensionKey.create(entity.dimension),
|
||||
delta: TeamRatingDelta.create(entity.delta),
|
||||
occurredAt: entity.occurredAt,
|
||||
createdAt: entity.createdAt,
|
||||
source: entity.source,
|
||||
reason: entity.reason,
|
||||
visibility: entity.visibility,
|
||||
version: entity.version,
|
||||
};
|
||||
|
||||
if (entity.weight !== undefined && entity.weight !== null) {
|
||||
props.weight = entity.weight;
|
||||
}
|
||||
|
||||
return TeamRatingEvent.rehydrate(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert domain entity to ORM entity
|
||||
*/
|
||||
static toOrmEntity(domain: TeamRatingEvent): TeamRatingEventOrmEntity {
|
||||
const entity = new TeamRatingEventOrmEntity();
|
||||
entity.id = domain.id.value;
|
||||
entity.teamId = domain.teamId;
|
||||
entity.dimension = domain.dimension.value;
|
||||
entity.delta = domain.delta.value;
|
||||
if (domain.weight !== undefined) {
|
||||
entity.weight = domain.weight;
|
||||
}
|
||||
entity.occurredAt = domain.occurredAt;
|
||||
entity.createdAt = domain.createdAt;
|
||||
entity.source = domain.source;
|
||||
entity.reason = domain.reason;
|
||||
entity.visibility = domain.visibility;
|
||||
entity.version = domain.version;
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { TeamRatingOrmMapper } from './TeamRatingOrmMapper';
|
||||
import { TeamRatingOrmEntity } from '../entities/TeamRatingOrmEntity';
|
||||
import { TeamRatingValue } from '@core/racing/domain/value-objects/TeamRatingValue';
|
||||
import { TeamRatingSnapshot } from '@core/racing/domain/services/TeamRatingSnapshotCalculator';
|
||||
|
||||
describe('TeamRatingOrmMapper', () => {
|
||||
const validEntityProps = {
|
||||
teamId: 'team-123',
|
||||
driving: {
|
||||
value: 65,
|
||||
confidence: 0.8,
|
||||
sampleSize: 10,
|
||||
trend: 'rising' as const,
|
||||
lastUpdated: new Date('2024-01-01T00:00:00Z'),
|
||||
},
|
||||
adminTrust: {
|
||||
value: 55,
|
||||
confidence: 0.8,
|
||||
sampleSize: 10,
|
||||
trend: 'stable' as const,
|
||||
lastUpdated: new Date('2024-01-01T00:00:00Z'),
|
||||
},
|
||||
overall: 62,
|
||||
calculatorVersion: '1.0',
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2024-01-01T00:00:00Z'),
|
||||
};
|
||||
|
||||
const validSnapshotProps: TeamRatingSnapshot = {
|
||||
teamId: 'team-123',
|
||||
driving: TeamRatingValue.create(65),
|
||||
adminTrust: TeamRatingValue.create(55),
|
||||
overall: 62,
|
||||
lastUpdated: new Date('2024-01-01T00:00:00Z'),
|
||||
eventCount: 10,
|
||||
};
|
||||
|
||||
describe('toDomain', () => {
|
||||
it('should convert ORM entity to domain snapshot', () => {
|
||||
const entity = Object.assign(new TeamRatingOrmEntity(), validEntityProps);
|
||||
const snapshot = TeamRatingOrmMapper.toDomain(entity);
|
||||
|
||||
expect(snapshot.teamId).toBe(validEntityProps.teamId);
|
||||
expect(snapshot.driving.value).toBe(validEntityProps.driving.value);
|
||||
expect(snapshot.adminTrust.value).toBe(validEntityProps.adminTrust.value);
|
||||
expect(snapshot.overall).toBe(validEntityProps.overall);
|
||||
expect(snapshot.lastUpdated).toEqual(validEntityProps.updatedAt);
|
||||
expect(snapshot.eventCount).toBe(validEntityProps.driving.sampleSize + validEntityProps.adminTrust.sampleSize);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toOrmEntity', () => {
|
||||
it('should convert domain snapshot to ORM entity', () => {
|
||||
const entity = TeamRatingOrmMapper.toOrmEntity(validSnapshotProps);
|
||||
|
||||
expect(entity.teamId).toBe(validSnapshotProps.teamId);
|
||||
expect(entity.driving.value).toBe(validSnapshotProps.driving.value);
|
||||
expect(entity.adminTrust.value).toBe(validSnapshotProps.adminTrust.value);
|
||||
expect(entity.overall).toBe(validSnapshotProps.overall);
|
||||
expect(entity.calculatorVersion).toBe('1.0');
|
||||
expect(entity.createdAt).toEqual(validSnapshotProps.lastUpdated);
|
||||
expect(entity.updatedAt).toEqual(validSnapshotProps.lastUpdated);
|
||||
|
||||
// Check calculated confidence
|
||||
expect(entity.driving.confidence).toBeGreaterThan(0);
|
||||
expect(entity.driving.confidence).toBeLessThan(1);
|
||||
expect(entity.adminTrust.confidence).toBeGreaterThan(0);
|
||||
expect(entity.adminTrust.confidence).toBeLessThan(1);
|
||||
|
||||
// Check sample size
|
||||
expect(entity.driving.sampleSize).toBe(validSnapshotProps.eventCount);
|
||||
expect(entity.adminTrust.sampleSize).toBe(validSnapshotProps.eventCount);
|
||||
});
|
||||
|
||||
it('should calculate correct trend for rising driving rating', () => {
|
||||
const snapshot: TeamRatingSnapshot = {
|
||||
teamId: 'team-123',
|
||||
driving: TeamRatingValue.create(65),
|
||||
adminTrust: TeamRatingValue.create(50),
|
||||
overall: 60,
|
||||
lastUpdated: new Date('2024-01-01T00:00:00Z'),
|
||||
eventCount: 5,
|
||||
};
|
||||
|
||||
const entity = TeamRatingOrmMapper.toOrmEntity(snapshot);
|
||||
expect(entity.driving.trend).toBe('rising');
|
||||
});
|
||||
|
||||
it('should calculate correct trend for falling driving rating', () => {
|
||||
const snapshot: TeamRatingSnapshot = {
|
||||
teamId: 'team-123',
|
||||
driving: TeamRatingValue.create(35),
|
||||
adminTrust: TeamRatingValue.create(50),
|
||||
overall: 40,
|
||||
lastUpdated: new Date('2024-01-01T00:00:00Z'),
|
||||
eventCount: 5,
|
||||
};
|
||||
|
||||
const entity = TeamRatingOrmMapper.toOrmEntity(snapshot);
|
||||
expect(entity.driving.trend).toBe('falling');
|
||||
});
|
||||
|
||||
it('should calculate correct trend for stable driving rating', () => {
|
||||
const snapshot: TeamRatingSnapshot = {
|
||||
teamId: 'team-123',
|
||||
driving: TeamRatingValue.create(50),
|
||||
adminTrust: TeamRatingValue.create(50),
|
||||
overall: 50,
|
||||
lastUpdated: new Date('2024-01-01T00:00:00Z'),
|
||||
eventCount: 5,
|
||||
};
|
||||
|
||||
const entity = TeamRatingOrmMapper.toOrmEntity(snapshot);
|
||||
expect(entity.driving.trend).toBe('stable');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { TeamRatingValue } from '@core/racing/domain/value-objects/TeamRatingValue';
|
||||
import { TeamRatingSnapshot } from '@core/racing/domain/services/TeamRatingSnapshotCalculator';
|
||||
import { TeamRatingOrmEntity } from '../entities/TeamRatingOrmEntity';
|
||||
|
||||
/**
|
||||
* Mapper: TeamRatingOrmMapper
|
||||
*
|
||||
* Converts between TeamRatingSnapshot domain value and TeamRatingOrmEntity.
|
||||
*/
|
||||
export class TeamRatingOrmMapper {
|
||||
/**
|
||||
* Convert ORM entity to domain snapshot
|
||||
*/
|
||||
static toDomain(entity: TeamRatingOrmEntity): TeamRatingSnapshot {
|
||||
return {
|
||||
teamId: entity.teamId,
|
||||
driving: TeamRatingValue.create(entity.driving.value),
|
||||
adminTrust: TeamRatingValue.create(entity.adminTrust.value),
|
||||
overall: entity.overall,
|
||||
lastUpdated: entity.updatedAt,
|
||||
eventCount: entity.driving.sampleSize + entity.adminTrust.sampleSize,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert domain snapshot to ORM entity
|
||||
*/
|
||||
static toOrmEntity(snapshot: TeamRatingSnapshot): TeamRatingOrmEntity {
|
||||
const entity = new TeamRatingOrmEntity();
|
||||
entity.teamId = snapshot.teamId;
|
||||
|
||||
// Calculate confidence based on event count
|
||||
const confidence = 1 - Math.exp(-snapshot.eventCount / 20);
|
||||
|
||||
entity.driving = {
|
||||
value: snapshot.driving.value,
|
||||
confidence: confidence,
|
||||
sampleSize: snapshot.eventCount,
|
||||
trend: snapshot.driving.value > 50 ? 'rising' : snapshot.driving.value < 50 ? 'falling' : 'stable',
|
||||
lastUpdated: snapshot.lastUpdated,
|
||||
};
|
||||
|
||||
entity.adminTrust = {
|
||||
value: snapshot.adminTrust.value,
|
||||
confidence: confidence,
|
||||
sampleSize: snapshot.eventCount,
|
||||
trend: snapshot.adminTrust.value > 50 ? 'rising' : snapshot.adminTrust.value < 50 ? 'falling' : 'stable',
|
||||
lastUpdated: snapshot.lastUpdated,
|
||||
};
|
||||
|
||||
entity.overall = snapshot.overall;
|
||||
entity.calculatorVersion = '1.0';
|
||||
entity.createdAt = snapshot.lastUpdated;
|
||||
entity.updatedAt = snapshot.lastUpdated;
|
||||
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import type { ITeamRatingEventRepository, FindByTeamIdOptions, PaginatedQueryOptions, PaginatedResult } from '@core/racing/domain/repositories/ITeamRatingEventRepository';
|
||||
import type { TeamRatingEvent } from '@core/racing/domain/entities/TeamRatingEvent';
|
||||
import type { TeamRatingEventId } from '@core/racing/domain/value-objects/TeamRatingEventId';
|
||||
|
||||
import { TeamRatingEventOrmEntity } from '../entities/TeamRatingEventOrmEntity';
|
||||
import { TeamRatingEventOrmMapper } from '../mappers/TeamRatingEventOrmMapper';
|
||||
|
||||
/**
|
||||
* TypeORM Implementation: ITeamRatingEventRepository
|
||||
*
|
||||
* Persists team rating events in the ledger with efficient querying by teamId
|
||||
* and ordering for snapshot computation.
|
||||
*/
|
||||
export class TypeOrmTeamRatingEventRepository implements ITeamRatingEventRepository {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async save(event: TeamRatingEvent): Promise<TeamRatingEvent> {
|
||||
const repo = this.dataSource.getRepository(TeamRatingEventOrmEntity);
|
||||
const entity = TeamRatingEventOrmMapper.toOrmEntity(event);
|
||||
await repo.save(entity);
|
||||
return event;
|
||||
}
|
||||
|
||||
async findByTeamId(teamId: string, options?: FindByTeamIdOptions): Promise<TeamRatingEvent[]> {
|
||||
const repo = this.dataSource.getRepository(TeamRatingEventOrmEntity);
|
||||
|
||||
const query = repo
|
||||
.createQueryBuilder('event')
|
||||
.where('event.teamId = :teamId', { teamId })
|
||||
.orderBy('event.occurredAt', 'ASC')
|
||||
.addOrderBy('event.createdAt', 'ASC')
|
||||
.addOrderBy('event.id', 'ASC');
|
||||
|
||||
if (options?.afterId) {
|
||||
query.andWhere('event.id > :afterId', { afterId: options.afterId.value });
|
||||
}
|
||||
|
||||
if (options?.limit) {
|
||||
query.limit(options.limit);
|
||||
}
|
||||
|
||||
const entities = await query.getMany();
|
||||
return entities.map(entity => TeamRatingEventOrmMapper.toDomain(entity));
|
||||
}
|
||||
|
||||
async findByIds(ids: TeamRatingEventId[]): Promise<TeamRatingEvent[]> {
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const repo = this.dataSource.getRepository(TeamRatingEventOrmEntity);
|
||||
const idValues = ids.map(id => id.value);
|
||||
|
||||
const entities = await repo
|
||||
.createQueryBuilder('event')
|
||||
.where('event.id IN (:...ids)', { ids: idValues })
|
||||
.orderBy('event.occurredAt', 'ASC')
|
||||
.addOrderBy('event.createdAt', 'ASC')
|
||||
.addOrderBy('event.id', 'ASC')
|
||||
.getMany();
|
||||
|
||||
return entities.map(entity => TeamRatingEventOrmMapper.toDomain(entity));
|
||||
}
|
||||
|
||||
async getAllByTeamId(teamId: string): Promise<TeamRatingEvent[]> {
|
||||
const repo = this.dataSource.getRepository(TeamRatingEventOrmEntity);
|
||||
|
||||
const entities = await repo
|
||||
.createQueryBuilder('event')
|
||||
.where('event.teamId = :teamId', { teamId })
|
||||
.orderBy('event.occurredAt', 'ASC')
|
||||
.addOrderBy('event.createdAt', 'ASC')
|
||||
.addOrderBy('event.id', 'ASC')
|
||||
.getMany();
|
||||
|
||||
return entities.map(entity => TeamRatingEventOrmMapper.toDomain(entity));
|
||||
}
|
||||
|
||||
async findEventsPaginated(teamId: string, options?: PaginatedQueryOptions): Promise<PaginatedResult<TeamRatingEvent>> {
|
||||
const repo = this.dataSource.getRepository(TeamRatingEventOrmEntity);
|
||||
|
||||
const query = repo
|
||||
.createQueryBuilder('event')
|
||||
.where('event.teamId = :teamId', { teamId });
|
||||
|
||||
// Apply filters
|
||||
if (options?.filter) {
|
||||
const filter = options.filter;
|
||||
|
||||
if (filter.dimensions) {
|
||||
query.andWhere('event.dimension IN (:...dimensions)', { dimensions: filter.dimensions });
|
||||
}
|
||||
|
||||
if (filter.sourceTypes) {
|
||||
query.andWhere('event.source.type IN (:...sourceTypes)', { sourceTypes: filter.sourceTypes });
|
||||
}
|
||||
|
||||
if (filter.from) {
|
||||
query.andWhere('event.occurredAt >= :from', { from: filter.from });
|
||||
}
|
||||
|
||||
if (filter.to) {
|
||||
query.andWhere('event.occurredAt <= :to', { to: filter.to });
|
||||
}
|
||||
|
||||
if (filter.reasonCodes) {
|
||||
query.andWhere('event.reason.code IN (:...reasonCodes)', { reasonCodes: filter.reasonCodes });
|
||||
}
|
||||
|
||||
if (filter.visibility) {
|
||||
query.andWhere('event.visibility.public = :visibility', { visibility: filter.visibility === 'public' });
|
||||
}
|
||||
}
|
||||
|
||||
// Get total count
|
||||
const total = await query.getCount();
|
||||
|
||||
// Apply pagination
|
||||
const limit = options?.limit ?? 10;
|
||||
const offset = options?.offset ?? 0;
|
||||
|
||||
query
|
||||
.orderBy('event.occurredAt', 'ASC')
|
||||
.addOrderBy('event.createdAt', 'ASC')
|
||||
.addOrderBy('event.id', 'ASC')
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const entities = await query.getMany();
|
||||
const items = entities.map(entity => TeamRatingEventOrmMapper.toDomain(entity));
|
||||
|
||||
const hasMore = offset + limit < total;
|
||||
const nextOffset = hasMore ? offset + limit : undefined;
|
||||
|
||||
const result: PaginatedResult<TeamRatingEvent> = {
|
||||
items,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
hasMore
|
||||
};
|
||||
|
||||
if (nextOffset !== undefined) {
|
||||
result.nextOffset = nextOffset;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import type { ITeamRatingRepository } from '@core/racing/domain/repositories/ITeamRatingRepository';
|
||||
import type { TeamRatingSnapshot } from '@core/racing/domain/services/TeamRatingSnapshotCalculator';
|
||||
|
||||
import { TeamRatingOrmEntity } from '../entities/TeamRatingOrmEntity';
|
||||
import { TeamRatingOrmMapper } from '../mappers/TeamRatingOrmMapper';
|
||||
|
||||
/**
|
||||
* TypeORM Implementation: ITeamRatingRepository
|
||||
*
|
||||
* Persists and retrieves TeamRating snapshots for fast reads.
|
||||
*/
|
||||
export class TypeOrmTeamRatingRepository implements ITeamRatingRepository {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async findByTeamId(teamId: string): Promise<TeamRatingSnapshot | null> {
|
||||
const repo = this.dataSource.getRepository(TeamRatingOrmEntity);
|
||||
const entity = await repo.findOne({ where: { teamId } });
|
||||
|
||||
if (!entity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TeamRatingOrmMapper.toDomain(entity);
|
||||
}
|
||||
|
||||
async save(teamRating: TeamRatingSnapshot): Promise<TeamRatingSnapshot> {
|
||||
const repo = this.dataSource.getRepository(TeamRatingOrmEntity);
|
||||
const entity = TeamRatingOrmMapper.toOrmEntity(teamRating);
|
||||
await repo.save(entity);
|
||||
return teamRating;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { DriverStats } from '@core/racing/domain/services/IDriverStatsService';
|
||||
|
||||
/**
|
||||
* Global store for driver stats that can be populated during seeding
|
||||
* and read by the InMemoryDriverStatsService
|
||||
*/
|
||||
export class DriverStatsStore {
|
||||
private static instance: DriverStatsStore;
|
||||
private statsMap = new Map<string, DriverStats>();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): DriverStatsStore {
|
||||
if (!DriverStatsStore.instance) {
|
||||
DriverStatsStore.instance = new DriverStatsStore();
|
||||
}
|
||||
return DriverStatsStore.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the store with stats (called during seeding)
|
||||
*/
|
||||
loadStats(stats: Map<string, DriverStats>): void {
|
||||
this.statsMap.clear();
|
||||
stats.forEach((input, driverId) => {
|
||||
this.statsMap.set(driverId, input);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stats for a specific driver
|
||||
*/
|
||||
getDriverStats(driverId: string): DriverStats | null {
|
||||
return this.statsMap.get(driverId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all stats (useful for reseeding)
|
||||
*/
|
||||
clear(): void {
|
||||
this.statsMap.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stats (for debugging)
|
||||
*/
|
||||
getAllStats(): Map<string, DriverStats> {
|
||||
return new Map(this.statsMap);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { InMemoryDriverStatsService } from './InMemoryDriverStatsService';
|
||||
|
||||
describe('InMemoryDriverStatsService', () => {
|
||||
it('returns stats for known drivers', () => {
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
const service = new InMemoryDriverStatsService(logger);
|
||||
|
||||
const stats = service.getDriverStats('driver-1');
|
||||
expect(stats?.rating).toBe(2500);
|
||||
|
||||
expect(service.getDriverStats('unknown')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { IDriverStatsService, DriverStats } from '@core/racing/domain/services/IDriverStatsService';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { DriverStatsStore } from './DriverStatsStore';
|
||||
|
||||
export class InMemoryDriverStatsService implements IDriverStatsService {
|
||||
private store: DriverStatsStore;
|
||||
|
||||
constructor(private readonly logger: Logger) {
|
||||
this.logger.info('InMemoryDriverStatsService initialized.');
|
||||
this.store = DriverStatsStore.getInstance();
|
||||
}
|
||||
|
||||
getDriverStats(driverId: string): DriverStats | null {
|
||||
this.logger.debug(`[InMemoryDriverStatsService] Getting stats for driver: ${driverId}`);
|
||||
return this.store.getDriverStats(driverId);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { InMemoryRankingService } from './InMemoryRankingService';
|
||||
|
||||
describe('InMemoryRankingService', () => {
|
||||
it('returns mock rankings', () => {
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
const service = new InMemoryRankingService(logger);
|
||||
const rankings = service.getAllDriverRankings();
|
||||
|
||||
expect(rankings.length).toBeGreaterThanOrEqual(3);
|
||||
expect(rankings[0]).toHaveProperty('driverId');
|
||||
expect(rankings[0]).toHaveProperty('rating');
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { IRankingService, DriverRanking } from '@core/racing/domain/services/IRankingService';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { DriverStatsStore } from './DriverStatsStore';
|
||||
|
||||
export class InMemoryRankingService implements IRankingService {
|
||||
constructor(private readonly logger: Logger) {
|
||||
this.logger.info('InMemoryRankingService initialized.');
|
||||
}
|
||||
|
||||
getAllDriverRankings(): DriverRanking[] {
|
||||
this.logger.debug('[InMemoryRankingService] Getting all driver rankings.');
|
||||
|
||||
// Get stats from the DriverStatsStore
|
||||
const statsStore = DriverStatsStore.getInstance();
|
||||
const allStats = statsStore.getAllStats();
|
||||
|
||||
// Convert stats to rankings
|
||||
const rankings: DriverRanking[] = [];
|
||||
|
||||
allStats.forEach((stats, driverId) => {
|
||||
rankings.push({
|
||||
driverId,
|
||||
rating: stats.rating,
|
||||
overallRank: stats.overallRank ?? 0,
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by rating descending to get proper rankings
|
||||
rankings.sort((a, b) => b.rating - a.rating);
|
||||
|
||||
// Assign ranks
|
||||
rankings.forEach((ranking, index) => {
|
||||
ranking.overallRank = index + 1;
|
||||
});
|
||||
|
||||
return rankings;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { TeamStats } from '@adapters/bootstrap/racing/RacingTeamFactory';
|
||||
|
||||
/**
|
||||
* Global store for team stats that can be populated during seeding
|
||||
* and read by the AllTeamsPresenter
|
||||
*/
|
||||
export class TeamStatsStore {
|
||||
private static instance: TeamStatsStore;
|
||||
private statsMap = new Map<string, TeamStats>();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): TeamStatsStore {
|
||||
if (!TeamStatsStore.instance) {
|
||||
TeamStatsStore.instance = new TeamStatsStore();
|
||||
}
|
||||
return TeamStatsStore.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the store with stats (called during seeding)
|
||||
*/
|
||||
loadStats(stats: Map<string, TeamStats>): void {
|
||||
this.statsMap.clear();
|
||||
stats.forEach((input, teamId) => {
|
||||
this.statsMap.set(teamId, input);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stats for a specific team
|
||||
*/
|
||||
getTeamStats(teamId: string): TeamStats | null {
|
||||
return this.statsMap.get(teamId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all stats (useful for reseeding)
|
||||
*/
|
||||
clear(): void {
|
||||
this.statsMap.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stats (for debugging)
|
||||
*/
|
||||
getAllStats(): Map<string, TeamStats> {
|
||||
return new Map(this.statsMap);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user