Files
gridpilot.gg/adapters/bootstrap/SeedRacingData.ts
2025-12-30 12:25:45 +01:00

632 lines
22 KiB
TypeScript

import type { Logger } from '@core/shared/application';
import type { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
import type { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository';
import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository';
import type { IStandingRepository } from '@core/racing/domain/repositories/IStandingRepository';
import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
import type { ITeamRepository } from '@core/racing/domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '@core/racing/domain/repositories/ITeamMembershipRepository';
import type { ISponsorRepository } from '@core/racing/domain/repositories/ISponsorRepository';
import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '@core/racing/domain/repositories/ILeagueScoringConfigRepository';
import type { ISeasonSponsorshipRepository } from '@core/racing/domain/repositories/ISeasonSponsorshipRepository';
import type { ISponsorshipRequestRepository } from '@core/racing/domain/repositories/ISponsorshipRequestRepository';
import type { ILeagueWalletRepository } from '@core/racing/domain/repositories/ILeagueWalletRepository';
import type { ITransactionRepository } from '@core/racing/domain/repositories/ITransactionRepository';
import type { Season } from '@core/racing/domain/entities/season/Season';
import { getLeagueScoringPresetById } from './LeagueScoringPresets';
import type { IProtestRepository } from '@core/racing/domain/repositories/IProtestRepository';
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 { 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;
leagueRepository: ILeagueRepository;
seasonRepository: ISeasonRepository;
leagueScoringConfigRepository: ILeagueScoringConfigRepository;
seasonSponsorshipRepository: ISeasonSponsorshipRepository;
sponsorshipRequestRepository: ISponsorshipRequestRepository;
leagueWalletRepository: ILeagueWalletRepository;
transactionRepository: ITransactionRepository;
protestRepository: IProtestRepository;
penaltyRepository: IPenaltyRepository;
raceRepository: IRaceRepository;
resultRepository: IResultRepository;
standingRepository: IStandingRepository;
leagueMembershipRepository: ILeagueMembershipRepository;
raceRegistrationRepository: IRaceRegistrationRepository;
teamRepository: ITeamRepository;
teamMembershipRepository: ITeamMembershipRepository;
sponsorRepository: ISponsorRepository;
feedRepository: IFeedRepository;
socialGraphRepository: ISocialGraphRepository;
driverStatsRepository: IDriverStatsRepository;
teamStatsRepository: ITeamStatsRepository;
mediaRepository: IMediaRepository;
};
export class SeedRacingData {
constructor(
private readonly logger: Logger,
private readonly seedDeps: RacingSeedDependencies,
) {}
private getApiPersistence(): 'postgres' | 'inmemory' {
const configured = process.env.GRIDPILOT_API_PERSISTENCE?.toLowerCase();
if (configured === 'postgres' || configured === 'inmemory') {
return configured;
}
if (process.env.NODE_ENV === 'test') {
return 'inmemory';
}
return process.env.DATABASE_URL ? 'postgres' : 'inmemory';
}
async execute(): Promise<void> {
const existingDrivers = await this.seedDeps.driverRepository.findAll();
const persistence = this.getApiPersistence();
// Check for force reseed via environment variable
const forceReseedRaw = process.env.GRIDPILOT_API_FORCE_RESEED;
const forceReseed = forceReseedRaw !== undefined && forceReseedRaw !== '0' && forceReseedRaw.toLowerCase() !== 'false';
if (existingDrivers.length > 0 && !forceReseed) {
this.logger.info('[Bootstrap] Racing seed skipped (drivers already exist), ensuring scoring configs');
await this.ensureScoringConfigsForExistingData();
return;
}
if (forceReseed && existingDrivers.length > 0) {
this.logger.info('[Bootstrap] Force reseed enabled - clearing existing racing data');
await this.clearExistingRacingData();
}
const seed = createRacingSeed({
persistence,
driverCount: 150 // Expanded from 100 to 150
});
// Clear existing stats repositories
await this.seedDeps.driverStatsRepository.clear();
await this.seedDeps.teamStatsRepository.clear();
await this.seedDeps.mediaRepository.clear();
this.logger.info('[Bootstrap] Cleared existing stats and media repositories');
let sponsorshipRequestsSeededViaRepo = false;
const seedableSponsorshipRequests = this.seedDeps
.sponsorshipRequestRepository as unknown as { seed?: (input: unknown) => void };
if (typeof seedableSponsorshipRequests.seed === 'function') {
seedableSponsorshipRequests.seed(seed.sponsorshipRequests);
sponsorshipRequestsSeededViaRepo = true;
}
for (const driver of seed.drivers) {
try {
await this.seedDeps.driverRepository.create(driver);
} catch {
// ignore duplicates
}
}
for (const league of seed.leagues) {
try {
await this.seedDeps.leagueRepository.create(league);
} catch {
// ignore duplicates
}
}
for (const season of seed.seasons) {
try {
await this.seedDeps.seasonRepository.create(season);
} catch {
// ignore duplicates
}
}
const activeSeasons = seed.seasons.filter((season) => season.status.isActive());
for (const season of activeSeasons) {
const presetId = this.selectScoringPresetIdForSeason(season, persistence);
const preset = getLeagueScoringPresetById(presetId);
if (!preset) {
this.logger.warn(
`[Bootstrap] Scoring preset not found (presetId=${presetId}, seasonId=${season.id}, leagueId=${season.leagueId})`,
);
continue;
}
const scoringConfig = preset.createConfig({ seasonId: season.id });
try {
await this.seedDeps.leagueScoringConfigRepository.save(scoringConfig);
} catch {
// ignore duplicates
}
}
for (const sponsorship of seed.seasonSponsorships) {
try {
await this.seedDeps.seasonSponsorshipRepository.create(sponsorship);
} catch {
// ignore duplicates
}
}
if (!sponsorshipRequestsSeededViaRepo) {
for (const request of seed.sponsorshipRequests) {
try {
await this.seedDeps.sponsorshipRequestRepository.create(request);
} catch {
// ignore duplicates
}
}
}
for (const wallet of seed.leagueWallets) {
try {
await this.seedDeps.leagueWalletRepository.create(wallet);
} catch {
// ignore duplicates
}
}
for (const tx of seed.leagueWalletTransactions) {
try {
await this.seedDeps.transactionRepository.create(tx);
} catch {
// ignore duplicates
}
}
for (const protest of seed.protests) {
try {
await this.seedDeps.protestRepository.create(protest);
} catch {
// ignore duplicates
}
}
for (const penalty of seed.penalties) {
try {
await this.seedDeps.penaltyRepository.create(penalty);
} catch {
// ignore duplicates
}
}
for (const race of seed.races) {
try {
await this.seedDeps.raceRepository.create(race);
} catch {
// ignore duplicates
}
}
try {
await this.seedDeps.resultRepository.createMany(seed.results);
} catch {
// ignore duplicates
}
for (const membership of seed.leagueMemberships) {
try {
await this.seedDeps.leagueMembershipRepository.saveMembership(membership);
} catch {
// ignore duplicates
}
}
for (const request of seed.leagueJoinRequests) {
try {
await this.seedDeps.leagueMembershipRepository.saveJoinRequest(request);
} catch {
// ignore duplicates
}
}
for (const team of seed.teams) {
try {
await this.seedDeps.teamRepository.create(team);
} catch {
// ignore duplicates
}
}
for (const sponsor of seed.sponsors) {
try {
await this.seedDeps.sponsorRepository.create(sponsor);
} catch {
// ignore duplicates
}
}
for (const membership of seed.teamMemberships) {
try {
await this.seedDeps.teamMembershipRepository.saveMembership(membership);
} catch {
// ignore duplicates
}
}
for (const request of seed.teamJoinRequests) {
try {
await this.seedDeps.teamMembershipRepository.saveJoinRequest(request);
} catch {
// ignore duplicates
}
}
for (const registration of seed.raceRegistrations) {
try {
await this.seedDeps.raceRegistrationRepository.register(registration);
} catch {
// ignore duplicates
}
}
try {
await this.seedDeps.standingRepository.saveMany(seed.standings);
} catch {
// ignore duplicates
}
const seedableFeed = this.seedDeps.feedRepository as unknown as { seed?: (input: unknown) => void };
if (typeof seedableFeed.seed === 'function') {
seedableFeed.seed({
drivers: seed.drivers,
friendships: seed.friendships,
feedEvents: seed.feedEvents,
});
}
const seedableSocial = this.seedDeps.socialGraphRepository as unknown as { seed?: (input: unknown) => void };
if (typeof seedableSocial.seed === 'function') {
seedableSocial.seed({
drivers: seed.drivers,
friendships: seed.friendships,
feedEvents: seed.feedEvents,
});
}
// 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();
// Delete drivers first (this should cascade to related data in most cases)
for (const driver of drivers) {
try {
await this.seedDeps.driverRepository.delete(driver.id);
} catch {
// Ignore errors
}
}
// Try to clean up other data if repositories support it
try {
const leagues = await this.seedDeps.leagueRepository.findAll();
for (const league of leagues) {
try {
await this.seedDeps.leagueRepository.delete(league.id.toString());
} catch {
// Ignore
}
}
} catch {
// Ignore
}
this.logger.info('[Bootstrap] Cleared existing racing data');
}
private async ensureScoringConfigsForExistingData(): Promise<void> {
const leagues = await this.seedDeps.leagueRepository.findAll();
for (const league of leagues) {
const seasons = await this.seedDeps.seasonRepository.findByLeagueId(league.id.toString());
const activeSeasons = seasons.filter((season) => season.status.isActive());
for (const season of activeSeasons) {
const existing = await this.seedDeps.leagueScoringConfigRepository.findBySeasonId(season.id);
if (existing) continue;
const presetId = this.selectScoringPresetIdForSeason(season, 'postgres');
const preset = getLeagueScoringPresetById(presetId);
if (!preset) {
this.logger.warn(
`[Bootstrap] Scoring preset not found (presetId=${presetId}, seasonId=${season.id}, leagueId=${season.leagueId})`,
);
continue;
}
const scoringConfig = preset.createConfig({ seasonId: season.id });
try {
await this.seedDeps.leagueScoringConfigRepository.save(scoringConfig);
} catch {
// ignore duplicates
}
}
}
}
private selectScoringPresetIdForSeason(season: Season, persistence: 'postgres' | 'inmemory'): string {
const expectedLeagueId = seedId('league-5', persistence);
const expectedSeasonId = seedId('season-1-b', persistence);
if (season.leagueId === expectedLeagueId && season.status.isActive()) {
return 'sprint-main-driver';
}
if (season.leagueId === seedId('league-3', persistence)) {
return season.id === expectedSeasonId ? 'sprint-main-team' : 'club-default-nations';
}
const match = /^league-(\d+)$/.exec(season.leagueId);
const leagueNumber = match ? Number(match[1]) : undefined;
if (leagueNumber !== undefined) {
switch (leagueNumber % 4) {
case 0:
return 'sprint-main-team';
case 1:
return 'endurance-main-trophy';
case 2:
return 'sprint-main-driver';
case 3:
return 'club-default-nations';
}
}
return 'club-default';
}
}