diff --git a/.env.development b/.env.development index 590539346..95563f7b5 100644 --- a/.env.development +++ b/.env.development @@ -15,6 +15,9 @@ NEXT_TELEMETRY_DISABLED=1 # API persistence is inferred from DATABASE_URL by default. # GRIDPILOT_API_PERSISTENCE=postgres +# Force reseed on every startup in development +GRIDPILOT_API_FORCE_RESEED=true + DATABASE_URL=postgres://gridpilot_user:gridpilot_dev_pass@db:5432/gridpilot_dev # Postgres container vars (used by `docker-compose.dev.yml` -> `db`) diff --git a/adapters/bootstrap/EnsureInitialData.ts b/adapters/bootstrap/EnsureInitialData.ts index 0f4c4d12f..21c2f1931 100644 --- a/adapters/bootstrap/EnsureInitialData.ts +++ b/adapters/bootstrap/EnsureInitialData.ts @@ -61,5 +61,61 @@ export class EnsureInitialData { } this.logger.info(`[Bootstrap] Achievements: ${createdCount} created, ${existingCount} already exist`); + + // Create additional users for comprehensive seeding + await this.createAdditionalUsers(); + + // Create user achievements linking users to achievements + await this.createUserAchievements(); + } + + private async createAdditionalUsers(): Promise { + const userConfigs = [ + // Driver with iRacing linked + { displayName: 'Max Verstappen', email: 'max@racing.com', password: 'Test123!', iracingCustomerId: '12345' }, + // Driver without email + { displayName: 'Lewis Hamilton', email: undefined, password: undefined, iracingCustomerId: '67890' }, + // Sponsor user + { displayName: 'Sponsor Inc', email: 'sponsor@example.com', password: 'Test123!', iracingCustomerId: undefined }, + // Various driver profiles + { displayName: 'Charles Leclerc', email: 'charles@ferrari.com', password: 'Test123!', iracingCustomerId: '11111' }, + { displayName: 'Lando Norris', email: 'lando@mclaren.com', password: 'Test123!', iracingCustomerId: '22222' }, + { displayName: 'George Russell', email: 'george@mercedes.com', password: 'Test123!', iracingCustomerId: '33333' }, + { displayName: 'Carlos Sainz', email: 'carlos@ferrari.com', password: 'Test123!', iracingCustomerId: '44444' }, + { displayName: 'Fernando Alonso', email: 'fernando@aston.com', password: 'Test123!', iracingCustomerId: '55555' }, + { displayName: 'Sergio Perez', email: 'sergio@redbull.com', password: 'Test123!', iracingCustomerId: '66666' }, + { displayName: 'Valtteri Bottas', email: 'valtteri@sauber.com', password: 'Test123!', iracingCustomerId: '77777' }, + ]; + + let createdCount = 0; + + for (const config of userConfigs) { + if (!config.email) { + // Skip users without email for now (would need different creation method) + continue; + } + + try { + const result = await this.signupUseCase.execute({ + email: config.email, + password: config.password!, + displayName: config.displayName, + }); + + if (result.isOk()) { + createdCount++; + } + } catch (error) { + // User might already exist, skip + } + } + + this.logger.info(`[Bootstrap] Created ${createdCount} additional users`); + } + + private async createUserAchievements(): Promise { + // This would require access to user and achievement repositories + // For now, we'll log that this would be done + this.logger.info('[Bootstrap] User achievements would be created here (requires repository access)'); } } diff --git a/adapters/bootstrap/SeedRacingData.ts b/adapters/bootstrap/SeedRacingData.ts index 21a0fe949..b201b926c 100644 --- a/adapters/bootstrap/SeedRacingData.ts +++ b/adapters/bootstrap/SeedRacingData.ts @@ -22,8 +22,9 @@ import type { IPenaltyRepository } from '@core/racing/domain/repositories/IPenal import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository'; import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository'; import { createRacingSeed } from './racing/RacingSeed'; -import { getApiPersistence } from '../../apps/api/src/env'; import { seedId } from './racing/SeedIdHelper'; +import { DriverStatsStore } from '@adapters/racing/services/DriverStatsStore'; +import { TeamStatsStore } from '@adapters/racing/services/TeamStatsStore'; export type RacingSeedDependencies = { driverRepository: IDriverRepository; @@ -54,16 +55,56 @@ export class SeedRacingData { 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 { const existingDrivers = await this.seedDeps.driverRepository.findAll(); - if (existingDrivers.length > 0) { + 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; } - const persistence = getApiPersistence(); - const seed = createRacingSeed({ persistence }); + 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 + }); + + // Populate the driver stats store for the InMemoryDriverStatsService + const driverStatsStore = DriverStatsStore.getInstance(); + driverStatsStore.clear(); // Clear any existing stats + driverStatsStore.loadStats(seed.driverStats); + + 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`); let sponsorshipRequestsSeededViaRepo = false; const seedableSponsorshipRequests = this.seedDeps @@ -268,6 +309,36 @@ export class SeedRacingData { ); } + private async clearExistingRacingData(): Promise { + // 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 { const leagues = await this.seedDeps.leagueRepository.findAll(); diff --git a/adapters/bootstrap/racing/RacingDriverFactory.ts b/adapters/bootstrap/racing/RacingDriverFactory.ts index 3c79f2e6d..e1272d3fd 100644 --- a/adapters/bootstrap/racing/RacingDriverFactory.ts +++ b/adapters/bootstrap/racing/RacingDriverFactory.ts @@ -2,6 +2,22 @@ import { Driver } from '@core/racing/domain/entities/Driver'; import { faker } from '@faker-js/faker'; import { seedId } from './SeedIdHelper'; +export interface DriverStats { + rating: number; + safetyRating: number; + sportsmanshipRating: number; + totalRaces: number; + wins: number; + podiums: number; + dnfs: number; + avgFinish: number; + bestFinish: number; + worstFinish: number; + consistency: number; + experienceLevel: 'beginner' | 'intermediate' | 'advanced' | 'veteran'; + overallRank: number | null; +} + export class RacingDriverFactory { constructor( private readonly driverCount: number, @@ -15,14 +31,156 @@ export class RacingDriverFactory { return Array.from({ length: this.driverCount }, (_, idx) => { const i = idx + 1; - return Driver.create({ + // Vary bio presence: 80% have bio, 20% empty + const hasBio = i % 5 !== 0; + + // Vary joined dates: some recent (new drivers), some old (veterans) + let joinedAt: Date; + if (i % 10 === 0) { + // Very recent drivers (within last week) + joinedAt = faker.date.recent({ days: 7, refDate: this.baseDate }); + } else if (i % 7 === 0) { + // Recent drivers (within last month) + joinedAt = faker.date.recent({ days: 30, refDate: this.baseDate }); + } else if (i % 5 === 0) { + // Medium tenure (6-12 months ago) + joinedAt = faker.date.between({ + from: new Date(this.baseDate.getTime() - 365 * 24 * 60 * 60 * 1000), + to: new Date(this.baseDate.getTime() - 180 * 24 * 60 * 60 * 1000) + }); + } else { + // Veterans (1-2 years ago) + joinedAt = faker.date.past({ years: 2, refDate: this.baseDate }); + } + + const driverData: { + id: string; + iracingId: string; + name: string; + country: string; + bio?: string; + joinedAt?: Date; + } = { id: seedId(`driver-${i}`, this.persistence), iracingId: String(100000 + i), name: faker.person.fullName(), country: faker.helpers.arrayElement(countries), - bio: faker.lorem.sentences(2), - joinedAt: faker.date.past({ years: 2, refDate: this.baseDate }), - }); + joinedAt, + }; + + if (hasBio) { + driverData.bio = faker.lorem.sentences(2); + } + + return Driver.create(driverData); }); } + + /** + * Generate driver statistics for profile display + * This would be stored in a separate stats table/service in production + */ + generateDriverStats(drivers: Driver[]): Map { + const statsMap = new Map(); + + drivers.forEach((driver, idx) => { + const i = idx + 1; + + // Determine experience level based on join date and index + let experienceLevel: 'beginner' | 'intermediate' | 'advanced' | 'veteran'; + let totalRaces: number; + let rating: number; + let safetyRating: number; + let sportsmanshipRating: number; + + if (i % 10 === 0) { + // Very recent drivers + experienceLevel = 'beginner'; + totalRaces = faker.number.int({ min: 1, max: 10 }); + rating = faker.number.int({ min: 800, max: 1200 }); + safetyRating = faker.number.int({ min: 60, max: 85 }); + sportsmanshipRating = Math.round(faker.number.float({ min: 3.0, max: 4.2 }) * 10) / 10; + } else if (i % 7 === 0) { + // Recent drivers + experienceLevel = 'beginner'; + totalRaces = faker.number.int({ min: 5, max: 25 }); + rating = faker.number.int({ min: 1000, max: 1400 }); + safetyRating = faker.number.int({ min: 70, max: 90 }); + sportsmanshipRating = Math.round(faker.number.float({ min: 3.5, max: 4.5 }) * 10) / 10; + } else if (i % 5 === 0) { + // Medium tenure + experienceLevel = 'intermediate'; + totalRaces = faker.number.int({ min: 20, max: 60 }); + rating = faker.number.int({ min: 1300, max: 1700 }); + safetyRating = faker.number.int({ min: 75, max: 95 }); + sportsmanshipRating = Math.round(faker.number.float({ min: 3.8, max: 4.8 }) * 10) / 10; + } else if (i % 3 === 0) { + // Advanced + experienceLevel = 'advanced'; + totalRaces = faker.number.int({ min: 50, max: 120 }); + rating = faker.number.int({ min: 1600, max: 1900 }); + safetyRating = faker.number.int({ min: 80, max: 98 }); + sportsmanshipRating = Math.round(faker.number.float({ min: 4.0, max: 5.0 }) * 10) / 10; + } else { + // Veterans + experienceLevel = 'veteran'; + totalRaces = faker.number.int({ min: 100, max: 200 }); + rating = faker.number.int({ min: 1700, max: 2000 }); + safetyRating = faker.number.int({ min: 85, max: 100 }); + sportsmanshipRating = Math.round(faker.number.float({ min: 4.2, max: 5.0 }) * 10) / 10; + } + + // Calculate performance stats based on total races + const winRate = experienceLevel === 'beginner' ? faker.number.float({ min: 0, max: 5, fractionDigits: 1 }) : + experienceLevel === 'intermediate' ? faker.number.float({ min: 2, max: 10, fractionDigits: 1 }) : + experienceLevel === 'advanced' ? faker.number.float({ min: 5, max: 15, fractionDigits: 1 }) : + faker.number.float({ min: 8, max: 20, fractionDigits: 1 }); + + const podiumRate = experienceLevel === 'beginner' ? faker.number.float({ min: 5, max: 15, fractionDigits: 1 }) : + experienceLevel === 'intermediate' ? faker.number.float({ min: 10, max: 25, fractionDigits: 1 }) : + experienceLevel === 'advanced' ? faker.number.float({ min: 15, max: 35, fractionDigits: 1 }) : + faker.number.float({ min: 20, max: 45, fractionDigits: 1 }); + + const wins = Math.round((winRate / 100) * totalRaces); + const podiums = Math.round((podiumRate / 100) * totalRaces); + const dnfs = Math.round(faker.number.float({ min: 0.05, max: 0.15, fractionDigits: 2 }) * totalRaces); + + const avgFinish = experienceLevel === 'beginner' ? faker.number.float({ min: 8, max: 15, fractionDigits: 1 }) : + experienceLevel === 'intermediate' ? faker.number.float({ min: 5, max: 10, fractionDigits: 1 }) : + experienceLevel === 'advanced' ? faker.number.float({ min: 3, max: 7, fractionDigits: 1 }) : + faker.number.float({ min: 2, max: 5, fractionDigits: 1 }); + + const bestFinish = experienceLevel === 'beginner' ? faker.number.int({ min: 3, max: 8 }) : + experienceLevel === 'intermediate' ? faker.number.int({ min: 1, max: 5 }) : + faker.number.int({ min: 1, max: 3 }); + + const worstFinish = experienceLevel === 'beginner' ? faker.number.int({ min: 12, max: 20 }) : + experienceLevel === 'intermediate' ? faker.number.int({ min: 8, max: 15 }) : + experienceLevel === 'advanced' ? faker.number.int({ min: 5, max: 12 }) : + faker.number.int({ min: 4, max: 10 }); + + const consistency = experienceLevel === 'beginner' ? faker.number.float({ min: 40, max: 65, fractionDigits: 0 }) : + experienceLevel === 'intermediate' ? faker.number.float({ min: 60, max: 80, fractionDigits: 0 }) : + experienceLevel === 'advanced' ? faker.number.float({ min: 75, max: 90, fractionDigits: 0 }) : + faker.number.float({ min: 85, max: 98, fractionDigits: 0 }); + + statsMap.set(driver.id.toString(), { + rating, + safetyRating, + sportsmanshipRating, + totalRaces, + wins, + podiums, + dnfs, + avgFinish: parseFloat(avgFinish.toFixed(1)), + bestFinish, + worstFinish, + consistency: parseFloat(consistency.toFixed(1)), + experienceLevel, + overallRank: null, // Will be calculated after all stats are loaded + }); + }); + + return statsMap; + } } \ No newline at end of file diff --git a/adapters/bootstrap/racing/RacingLeagueFactory.ts b/adapters/bootstrap/racing/RacingLeagueFactory.ts index 9073a1040..fa2277c55 100644 --- a/adapters/bootstrap/racing/RacingLeagueFactory.ts +++ b/adapters/bootstrap/racing/RacingLeagueFactory.ts @@ -11,29 +11,273 @@ export class RacingLeagueFactory { ) {} create(): League[] { - const leagueCount = 20; + const leagueCount = 30; - // Create diverse league configurations + // Create diverse league configurations covering ALL enum combinations + // Points systems: f1-2024, indycar, custom (3) + // Qualifying formats: single-lap, open (2) + // Visibility: ranked, unranked (2) + // Decision modes: admin_only, steward_decides, steward_vote, member_vote, steward_veto, member_veto (6) + // Total combinations: 3 * 2 * 2 * 6 = 72, but we'll sample 30 covering extremes + const leagueConfigs = [ - // Small sprint leagues - { maxDrivers: 16, sessionDuration: 30, pointsSystem: 'f1-2024' as const, qualifyingFormat: 'single-lap' as const }, - { maxDrivers: 20, sessionDuration: 45, pointsSystem: 'f1-2024' as const, qualifyingFormat: 'open' as const }, - // Medium endurance leagues - { maxDrivers: 24, sessionDuration: 60, pointsSystem: 'indycar' as const, qualifyingFormat: 'open' as const }, - { maxDrivers: 28, sessionDuration: 90, pointsSystem: 'custom' as const, qualifyingFormat: 'open' as const }, - // Large mixed leagues - { maxDrivers: 32, sessionDuration: 120, pointsSystem: 'f1-2024' as const, qualifyingFormat: 'open' as const }, - { maxDrivers: 36, sessionDuration: 75, pointsSystem: 'indycar' as const, qualifyingFormat: 'single-lap' as const }, - { maxDrivers: 40, sessionDuration: 100, pointsSystem: 'custom' as const, qualifyingFormat: 'open' as const }, - { maxDrivers: 44, sessionDuration: 85, pointsSystem: 'f1-2024' as const, qualifyingFormat: 'open' as const }, - { maxDrivers: 48, sessionDuration: 110, pointsSystem: 'indycar' as const, qualifyingFormat: 'single-lap' as const }, - { maxDrivers: 50, sessionDuration: 95, pointsSystem: 'custom' as const, qualifyingFormat: 'open' as const }, + // 1-5: Ranked, F1-2024, various stewarding + { + pointsSystem: 'f1-2024' as const, + qualifyingFormat: 'single-lap' as const, + visibility: 'ranked' as const, + maxDrivers: 40, + sessionDuration: 60, + stewarding: { decisionMode: 'admin_only' as const, requireDefense: false } + }, + { + pointsSystem: 'f1-2024' as const, + qualifyingFormat: 'open' as const, + visibility: 'ranked' as const, + maxDrivers: 32, + sessionDuration: 45, + stewarding: { decisionMode: 'steward_vote' as const, requiredVotes: 3, requireDefense: true, defenseTimeLimit: 24 } + }, + { + pointsSystem: 'f1-2024' as const, + qualifyingFormat: 'single-lap' as const, + visibility: 'ranked' as const, + maxDrivers: 50, + sessionDuration: 90, + stewarding: { decisionMode: 'member_vote' as const, requiredVotes: 5, requireDefense: false } + }, + { + pointsSystem: 'f1-2024' as const, + qualifyingFormat: 'open' as const, + visibility: 'ranked' as const, + maxDrivers: 24, + sessionDuration: 30, + stewarding: { decisionMode: 'steward_veto' as const, requiredVotes: 2, requireDefense: true } + }, + { + pointsSystem: 'f1-2024' as const, + qualifyingFormat: 'single-lap' as const, + visibility: 'ranked' as const, + maxDrivers: 60, + sessionDuration: 120, + stewarding: { decisionMode: 'member_veto' as const, requiredVotes: 4, requireDefense: false } + }, + + // 6-10: Ranked, IndyCar, various stewarding + { + pointsSystem: 'indycar' as const, + qualifyingFormat: 'open' as const, + visibility: 'ranked' as const, + maxDrivers: 28, + sessionDuration: 75, + stewarding: { decisionMode: 'steward_decides' as const, requireDefense: true, defenseTimeLimit: 48 } + }, + { + pointsSystem: 'indycar' as const, + qualifyingFormat: 'single-lap' as const, + visibility: 'ranked' as const, + maxDrivers: 36, + sessionDuration: 60, + stewarding: { decisionMode: 'admin_only' as const, requireDefense: false } + }, + { + pointsSystem: 'indycar' as const, + qualifyingFormat: 'open' as const, + visibility: 'ranked' as const, + maxDrivers: 44, + sessionDuration: 100, + stewarding: { decisionMode: 'steward_vote' as const, requiredVotes: 4, requireDefense: true, voteTimeLimit: 72 } + }, + { + pointsSystem: 'indycar' as const, + qualifyingFormat: 'single-lap' as const, + visibility: 'ranked' as const, + maxDrivers: 20, + sessionDuration: 45, + stewarding: { decisionMode: 'member_vote' as const, requiredVotes: 6, requireDefense: false } + }, + { + pointsSystem: 'indycar' as const, + qualifyingFormat: 'open' as const, + visibility: 'ranked' as const, + maxDrivers: 48, + sessionDuration: 110, + stewarding: { decisionMode: 'steward_veto' as const, requiredVotes: 3, requireDefense: true } + }, + + // 11-15: Ranked, Custom, various stewarding + { + pointsSystem: 'custom' as const, + qualifyingFormat: 'single-lap' as const, + visibility: 'ranked' as const, + maxDrivers: 32, + sessionDuration: 50, + stewarding: { decisionMode: 'member_veto' as const, requiredVotes: 5, requireDefense: false } + }, + { + pointsSystem: 'custom' as const, + qualifyingFormat: 'open' as const, + visibility: 'ranked' as const, + maxDrivers: 40, + sessionDuration: 85, + stewarding: { decisionMode: 'admin_only' as const, requireDefense: false } + }, + { + pointsSystem: 'custom' as const, + qualifyingFormat: 'single-lap' as const, + visibility: 'ranked' as const, + maxDrivers: 52, + sessionDuration: 95, + stewarding: { decisionMode: 'steward_decides' as const, requireDefense: true, defenseTimeLimit: 36 } + }, + { + pointsSystem: 'custom' as const, + qualifyingFormat: 'open' as const, + visibility: 'ranked' as const, + maxDrivers: 26, + sessionDuration: 65, + stewarding: { decisionMode: 'steward_vote' as const, requiredVotes: 2, requireDefense: false } + }, + { + pointsSystem: 'custom' as const, + qualifyingFormat: 'single-lap' as const, + visibility: 'ranked' as const, + maxDrivers: 56, + sessionDuration: 105, + stewarding: { decisionMode: 'member_vote' as const, requiredVotes: 7, requireDefense: true, protestDeadlineHours: 24 } + }, + + // 16-20: Unranked, F1-2024, various stewarding (smaller, no participant requirements) + { + pointsSystem: 'f1-2024' as const, + qualifyingFormat: 'open' as const, + visibility: 'unranked' as const, + maxDrivers: 8, + sessionDuration: 30, + stewarding: { decisionMode: 'admin_only' as const, requireDefense: false } + }, + { + pointsSystem: 'f1-2024' as const, + qualifyingFormat: 'single-lap' as const, + visibility: 'unranked' as const, + maxDrivers: 12, + sessionDuration: 45, + stewarding: { decisionMode: 'steward_decides' as const, requireDefense: true } + }, + { + pointsSystem: 'f1-2024' as const, + qualifyingFormat: 'open' as const, + visibility: 'unranked' as const, + maxDrivers: 16, + sessionDuration: 60, + stewarding: { decisionMode: 'steward_vote' as const, requiredVotes: 2, requireDefense: false } + }, + { + pointsSystem: 'f1-2024' as const, + qualifyingFormat: 'single-lap' as const, + visibility: 'unranked' as const, + maxDrivers: 10, + sessionDuration: 35, + stewarding: { decisionMode: 'member_vote' as const, requiredVotes: 3, requireDefense: true } + }, + { + pointsSystem: 'f1-2024' as const, + qualifyingFormat: 'open' as const, + visibility: 'unranked' as const, + maxDrivers: 20, + sessionDuration: 55, + stewarding: { decisionMode: 'steward_veto' as const, requiredVotes: 2, requireDefense: false } + }, + + // 21-25: Unranked, IndyCar, various stewarding + { + pointsSystem: 'indycar' as const, + qualifyingFormat: 'single-lap' as const, + visibility: 'unranked' as const, + maxDrivers: 14, + sessionDuration: 40, + stewarding: { decisionMode: 'member_veto' as const, requiredVotes: 3, requireDefense: true } + }, + { + pointsSystem: 'indycar' as const, + qualifyingFormat: 'open' as const, + visibility: 'unranked' as const, + maxDrivers: 18, + sessionDuration: 50, + stewarding: { decisionMode: 'admin_only' as const, requireDefense: false } + }, + { + pointsSystem: 'indycar' as const, + qualifyingFormat: 'single-lap' as const, + visibility: 'unranked' as const, + maxDrivers: 22, + sessionDuration: 65, + stewarding: { decisionMode: 'steward_decides' as const, requireDefense: true, defenseTimeLimit: 12 } + }, + { + pointsSystem: 'indycar' as const, + qualifyingFormat: 'open' as const, + visibility: 'unranked' as const, + maxDrivers: 6, + sessionDuration: 25, + stewarding: { decisionMode: 'steward_vote' as const, requiredVotes: 2, requireDefense: false } + }, + { + pointsSystem: 'indycar' as const, + qualifyingFormat: 'single-lap' as const, + visibility: 'unranked' as const, + maxDrivers: 24, + sessionDuration: 70, + stewarding: { decisionMode: 'member_vote' as const, requiredVotes: 4, requireDefense: true } + }, + + // 26-30: Unranked, Custom, various stewarding + { + pointsSystem: 'custom' as const, + qualifyingFormat: 'open' as const, + visibility: 'unranked' as const, + maxDrivers: 12, + sessionDuration: 35, + stewarding: { decisionMode: 'steward_veto' as const, requiredVotes: 2, requireDefense: false } + }, + { + pointsSystem: 'custom' as const, + qualifyingFormat: 'single-lap' as const, + visibility: 'unranked' as const, + maxDrivers: 16, + sessionDuration: 45, + stewarding: { decisionMode: 'member_veto' as const, requiredVotes: 3, requireDefense: true } + }, + { + pointsSystem: 'custom' as const, + qualifyingFormat: 'open' as const, + visibility: 'unranked' as const, + maxDrivers: 20, + sessionDuration: 55, + stewarding: { decisionMode: 'admin_only' as const, requireDefense: false } + }, + { + pointsSystem: 'custom' as const, + qualifyingFormat: 'single-lap' as const, + visibility: 'unranked' as const, + maxDrivers: 8, + sessionDuration: 30, + stewarding: { decisionMode: 'steward_decides' as const, requireDefense: true, defenseTimeLimit: 18 } + }, + { + pointsSystem: 'custom' as const, + qualifyingFormat: 'open' as const, + visibility: 'unranked' as const, + maxDrivers: 24, + sessionDuration: 60, + stewarding: { decisionMode: 'steward_vote' as const, requiredVotes: 3, requireDefense: false } + }, ]; return Array.from({ length: leagueCount }, (_, idx) => { const i = idx + 1; const owner = faker.helpers.arrayElement(this.drivers); - const config = leagueConfigs[idx % leagueConfigs.length]!; + const config = leagueConfigs[idx]!; const createdAt = // Ensure some "New" leagues (created within 7 days) so `/leagues` featured categories are populated. @@ -41,6 +285,31 @@ export class RacingLeagueFactory { ? faker.date.recent({ days: 6, refDate: this.baseDate }) : faker.date.past({ years: 2, refDate: this.baseDate }); + // Calculate participant count based on visibility and league + let participantCount: number; + if (config.visibility === 'ranked') { + // Ranked leagues need >= 10 participants + // Some are full (max), some have minimum, some have none + if (idx % 5 === 0) { + participantCount = config.maxDrivers; // Full league + } else if (idx % 3 === 0) { + participantCount = 12; // Just meets minimum + } else if (idx % 7 === 0) { + participantCount = 0; // Empty but valid + } else { + participantCount = faker.number.int({ min: 10, max: config.maxDrivers - 5 }); + } + } else { + // Unranked can have any count, but must have at least 2 if not empty + if (idx % 4 === 0) { + participantCount = config.maxDrivers; // Full + } else if (idx % 5 === 0) { + participantCount = 0; // Empty + } else { + participantCount = faker.number.int({ min: 2, max: Math.min(config.maxDrivers, 15) }); + } + } + const leagueData: { id: string; name: string; @@ -51,6 +320,18 @@ export class RacingLeagueFactory { maxDrivers: number; sessionDuration: number; qualifyingFormat: 'open' | 'single-lap'; + visibility?: 'ranked' | 'unranked'; + stewarding?: { + decisionMode: 'admin_only' | 'steward_decides' | 'steward_vote' | 'member_vote' | 'steward_veto' | 'member_veto'; + requiredVotes?: number; + requireDefense?: boolean; + defenseTimeLimit?: number; + voteTimeLimit?: number; + protestDeadlineHours?: number; + stewardingClosesHours?: number; + notifyAccusedOnProtest?: boolean; + notifyOnVoteRequired?: boolean; + }; }; createdAt: Date; socialLinks?: { discordUrl?: string; youtubeUrl?: string; websiteUrl?: string }; @@ -60,11 +341,23 @@ export class RacingLeagueFactory { name: faker.company.name() + ' Racing League', description: faker.lorem.sentences(2), ownerId: owner.id.toString(), - settings: config, + settings: { + pointsSystem: config.pointsSystem, + maxDrivers: config.maxDrivers, + sessionDuration: config.sessionDuration, + qualifyingFormat: config.qualifyingFormat, + visibility: config.visibility, + stewarding: { + ...config.stewarding, + voteTimeLimit: 72, + protestDeadlineHours: 48, + stewardingClosesHours: 168, + notifyAccusedOnProtest: true, + notifyOnVoteRequired: true, + }, + }, createdAt, - // Start with some participants for ranked leagues to meet minimum requirements - // Note: ranked leagues require >= 10 participants (see LeagueVisibility) - participantCount: i % 3 === 0 ? 12 : 0, + participantCount, }; // Add social links with varying completeness diff --git a/adapters/bootstrap/racing/RacingRaceFactory.ts b/adapters/bootstrap/racing/RacingRaceFactory.ts index 8487d661b..549c9b4e0 100644 --- a/adapters/bootstrap/racing/RacingRaceFactory.ts +++ b/adapters/bootstrap/racing/RacingRaceFactory.ts @@ -21,7 +21,7 @@ export class RacingRaceFactory { // 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 <= 50; i++) { + for (let i = 1; i <= 100; i++) { const leagueId = leagueIds[(i - 1) % leagueIds.length] ?? demoLeagueId; const trackId = trackIds[(i - 1) % trackIds.length]!; const track = tracks.find(t => t.id === trackId)!; diff --git a/adapters/bootstrap/racing/RacingSeasonSponsorshipFactory.ts b/adapters/bootstrap/racing/RacingSeasonSponsorshipFactory.ts index 891c9caf6..fd358b1a2 100644 --- a/adapters/bootstrap/racing/RacingSeasonSponsorshipFactory.ts +++ b/adapters/bootstrap/racing/RacingSeasonSponsorshipFactory.ts @@ -19,111 +19,143 @@ export class RacingSeasonSponsorshipFactory { for (const league of leagues) { const leagueId = league.id.toString(); + const leagueIndex = parseInt(leagueId.split('-')[1] || '0'); - if (leagueId === seedId('league-5', this.persistence)) { - seasons.push( - Season.create({ - id: seedId('season-1', this.persistence), - leagueId, - gameId: 'iracing', - name: 'Season 1 (GT Sprint)', - year: 2025, - order: 1, - status: 'active', - startDate: this.daysFromBase(-30), - }), - Season.create({ - id: seedId('season-2', this.persistence), - leagueId, - gameId: 'iracing', - name: 'Season 2 (Endurance Cup)', - year: 2024, - order: 0, - status: 'completed', - startDate: this.daysFromBase(-120), - endDate: this.daysFromBase(-60), - }), - Season.create({ - id: seedId('season-3', this.persistence), - leagueId, - gameId: 'iracing', - name: 'Season 3 (Planned)', - year: 2025, - order: 2, - status: 'planned', - startDate: this.daysFromBase(14), - }), - ); - continue; - } - - if (leagueId === seedId('league-3', this.persistence)) { - seasons.push( - Season.create({ - id: seedId('league-3-season-a', this.persistence), - leagueId, - gameId: 'iracing', - name: 'Split Season A', - year: 2025, - order: 1, - status: 'active', - startDate: this.daysFromBase(-10), - }), - Season.create({ - id: seedId('league-3-season-b', this.persistence), - leagueId, - gameId: 'iracing', - name: 'Split Season B', - year: 2025, - order: 2, - status: 'active', - startDate: this.daysFromBase(-3), - }), - ); - continue; - } - - const baseYear = this.baseDate.getUTCFullYear(); - const seasonCount = leagueId === seedId('league-2', this.persistence) ? 1 : faker.number.int({ min: 1, max: 3 }); + // Create 2-4 seasons per league to reach ~100 total seasons + const seasonCount = faker.number.int({ min: 2, max: 4 }); for (let i = 0; i < seasonCount; i++) { const id = seedId(`${leagueId}-season-${i + 1}`, this.persistence); - const isFirst = i === 0; + + // Systematically cover all 5 statuses across all leagues + // planned: 20%, active: 20%, completed: 30%, archived: 20%, cancelled: 10% + let status: SeasonStatusValue; + if (i === 0 && leagueIndex % 5 === 0) { + status = 'planned'; + } else if (i === 0 && leagueIndex % 5 === 1) { + status = 'active'; + } else if (i === 1 && leagueIndex % 5 === 2) { + status = 'completed'; + } else if (i === 2 && leagueIndex % 5 === 3) { + status = 'archived'; + } else if (i === 3 && leagueIndex % 5 === 4) { + status = 'cancelled'; + } else { + // Weighted random distribution + const statusWeights = [ + { weight: 2, value: 'planned' as const }, + { weight: 2, value: 'active' as const }, + { weight: 3, value: 'completed' as const }, + { weight: 2, value: 'archived' as const }, + { weight: 1, value: 'cancelled' as const }, + ]; + const totalWeight = statusWeights.reduce((sum, item) => sum + item.weight, 0); + const random = faker.number.int({ min: 1, max: totalWeight }); + let cumulative = 0; + status = 'planned'; + for (const item of statusWeights) { + cumulative += item.weight; + if (random <= cumulative) { + status = item.value; + break; + } + } + } - const status: SeasonStatusValue = - leagueId === seedId('league-1', this.persistence) && isFirst - ? 'active' - : leagueId === seedId('league-2', this.persistence) - ? 'planned' - : isFirst - ? faker.helpers.arrayElement(['active', 'planned'] as const) - : faker.helpers.arrayElement(['completed', 'archived', 'cancelled'] as const); + const baseYear = this.baseDate.getUTCFullYear() + faker.number.int({ min: -1, max: 1 }); - const startOffset = - status === 'active' - ? faker.number.int({ min: -60, max: -1 }) - : status === 'planned' - ? faker.number.int({ min: 7, max: 60 }) - : faker.number.int({ min: -200, max: -90 }); + // Calculate dates based on status + let startDate: Date | undefined; + let endDate: Date | undefined; + let schedulePublished: boolean | undefined; + let participantCount: number | undefined; + let maxDrivers: number | undefined; - const endOffset = - status === 'completed' || status === 'archived' || status === 'cancelled' - ? faker.number.int({ min: -89, max: -7 }) - : undefined; + switch (status) { + case 'planned': + startDate = this.daysFromBase(faker.number.int({ min: 7, max: 90 })); + schedulePublished = faker.datatype.boolean({ probability: 0.6 }); + participantCount = 0; + break; - seasons.push( - Season.create({ - id, - leagueId, - gameId: 'iracing', - name: `${faker.word.adjective()} ${faker.word.noun()} Season`, - year: baseYear + faker.number.int({ min: -1, max: 1 }), - order: i + 1, - status, - startDate: this.daysFromBase(startOffset), - ...(endOffset !== undefined ? { endDate: this.daysFromBase(endOffset) } : {}), - }), - ); + case 'active': + startDate = this.daysFromBase(faker.number.int({ min: -60, max: -1 })); + schedulePublished = true; + maxDrivers = faker.number.int({ + min: 10, + max: Math.max(10, Math.min(league.settings.maxDrivers || 32, 100)) + }); + participantCount = faker.number.int({ min: 5, max: maxDrivers }); + break; + + case 'completed': + startDate = this.daysFromBase(faker.number.int({ min: -180, max: -60 })); + endDate = this.daysFromBase(faker.number.int({ min: -59, max: -7 })); + schedulePublished = true; + maxDrivers = faker.number.int({ + min: 10, + max: Math.max(10, Math.min(league.settings.maxDrivers || 32, 100)) + }); + participantCount = faker.number.int({ min: 10, max: maxDrivers }); + break; + + case 'archived': + startDate = this.daysFromBase(faker.number.int({ min: -365, max: -200 })); + endDate = this.daysFromBase(faker.number.int({ min: -199, max: -150 })); + schedulePublished = true; + maxDrivers = faker.number.int({ + min: 10, + max: Math.max(10, Math.min(league.settings.maxDrivers || 32, 100)) + }); + participantCount = faker.number.int({ min: 8, max: maxDrivers }); + break; + + case 'cancelled': + startDate = this.daysFromBase(faker.number.int({ min: -30, max: -1 })); + endDate = this.daysFromBase(faker.number.int({ min: -1, max: 1 })); // Cancelled early + schedulePublished = faker.datatype.boolean({ probability: 0.3 }); + // Cancelled seasons can have maxDrivers but participantCount should be low + maxDrivers = faker.number.int({ + min: 5, + max: Math.max(5, Math.min(league.settings.maxDrivers || 32, 100)) + }); + participantCount = faker.number.int({ min: 0, max: Math.min(5, maxDrivers) }); // Minimal participants + break; + } + + // Build season data with proper undefined handling + const seasonData: { + id: string; + leagueId: string; + gameId: string; + name: string; + year?: number; + order?: number; + status: SeasonStatusValue; + startDate?: Date; + endDate?: Date; + schedulePublished?: boolean; + participantCount?: number; + maxDrivers?: number; + } = { + id, + leagueId, + gameId: 'iracing', + name: `${faker.word.adjective()} ${faker.word.noun()} Season`, + status, + }; + + // Add optional fields only if they have values + if (baseYear !== undefined) seasonData.year = baseYear; + if (i + 1 !== undefined) seasonData.order = i + 1; + if (startDate !== undefined) seasonData.startDate = startDate; + if (endDate !== undefined) seasonData.endDate = endDate; + if (schedulePublished !== undefined) seasonData.schedulePublished = schedulePublished; + if (participantCount !== undefined) seasonData.participantCount = participantCount; + if (maxDrivers !== undefined) seasonData.maxDrivers = maxDrivers; + + const season = Season.create(seasonData); + seasons.push(season); } } diff --git a/adapters/bootstrap/racing/RacingSeed.ts b/adapters/bootstrap/racing/RacingSeed.ts index c1d6ad5e3..f4ef7af80 100644 --- a/adapters/bootstrap/racing/RacingSeed.ts +++ b/adapters/bootstrap/racing/RacingSeed.ts @@ -14,7 +14,7 @@ import { Season } from '@core/racing/domain/entities/season/Season'; import { SeasonSponsorship } from '@core/racing/domain/entities/season/SeasonSponsorship'; import type { TeamJoinRequest, TeamMembership } from '@core/racing/domain/types/TeamMembership'; import type { FeedItem } from '@core/social/domain/types/FeedItem'; -import { RacingDriverFactory } from './RacingDriverFactory'; +import { RacingDriverFactory, type DriverStats } from './RacingDriverFactory'; import { RacingFeedFactory } from './RacingFeedFactory'; import { RacingFriendshipFactory } from './RacingFriendshipFactory'; import { RacingLeagueFactory } from './RacingLeagueFactory'; @@ -22,7 +22,7 @@ import { RacingMembershipFactory } from './RacingMembershipFactory'; import { RacingRaceFactory } from './RacingRaceFactory'; import { RacingResultFactory } from './RacingResultFactory'; import { RacingStandingFactory } from './RacingStandingFactory'; -import { RacingTeamFactory } from './RacingTeamFactory'; +import { RacingTeamFactory, type TeamStats } from './RacingTeamFactory'; import { RacingTrackFactory } from './RacingTrackFactory'; import { RacingSponsorFactory } from './RacingSponsorFactory'; import { RacingSeasonSponsorshipFactory } from './RacingSeasonSponsorshipFactory'; @@ -36,6 +36,7 @@ export type Friendship = { export type RacingSeed = { drivers: Driver[]; + driverStats: Map; leagues: League[]; seasons: Season[]; seasonSponsorships: SeasonSponsorship[]; @@ -51,6 +52,7 @@ export type RacingSeed = { leagueJoinRequests: JoinRequest[]; raceRegistrations: RaceRegistration[]; teams: Team[]; + teamStats: Map; teamMemberships: TeamMembership[]; teamJoinRequests: TeamJoinRequest[]; sponsors: Sponsor[]; @@ -68,7 +70,7 @@ export type RacingSeedOptions = { export const racingSeedDefaults: Readonly< Required > = { - driverCount: 100, + driverCount: 150, // Increased from 100 to 150 baseDate: new Date(), persistence: 'inmemory', }; @@ -98,6 +100,7 @@ class RacingSeedFactory { const feedFactory = new RacingFeedFactory(this.baseDate, this.persistence); const drivers = driverFactory.create(); + const driverStats = driverFactory.generateDriverStats(drivers); const tracks = trackFactory.create(); const leagueFactory = new RacingLeagueFactory(this.baseDate, drivers, this.persistence); const leagues = leagueFactory.create(); @@ -113,6 +116,7 @@ class RacingSeedFactory { const teamFactory = new RacingTeamFactory(this.baseDate, this.persistence); const teams = teamFactory.createTeams(drivers, leagues); + const teamStats = teamFactory.generateTeamStats(teams); const races = raceFactory.create(leagues, tracks); const results = resultFactory.create(drivers, races); const standings = standingFactory.create(leagues, races, results); @@ -128,6 +132,7 @@ class RacingSeedFactory { return { drivers, + driverStats, leagues, seasons, seasonSponsorships, @@ -143,6 +148,7 @@ class RacingSeedFactory { leagueJoinRequests, raceRegistrations, teams, + teamStats, teamMemberships, teamJoinRequests, sponsors, diff --git a/adapters/bootstrap/racing/RacingStewardingFactory.ts b/adapters/bootstrap/racing/RacingStewardingFactory.ts index 38e80f16d..f5bfb0d8c 100644 --- a/adapters/bootstrap/racing/RacingStewardingFactory.ts +++ b/adapters/bootstrap/racing/RacingStewardingFactory.ts @@ -259,6 +259,101 @@ export class RacingStewardingFactory { } } + // Add comprehensive penalty coverage for all types and statuses + // This ensures we have examples of every penalty type in every status + const penaltyTypes = ['time_penalty', 'grid_penalty', 'points_deduction', 'disqualification', 'warning', 'license_points', 'probation', 'fine', 'race_ban'] as const; + const penaltyStatuses = ['pending', 'applied', 'appealed', 'overturned'] as const; + + // Get some races and members for penalty seeding + const allLeagueIds = Array.from(racesByLeague.keys()); + + for (const leagueId of allLeagueIds) { + const members = activeMembersByLeague.get(leagueId) ?? []; + if (members.length < 2) continue; + + const leagueRaces = racesByLeague.get(leagueId) ?? []; + const completedRaces = leagueRaces.filter((r) => r.status.toString() === 'completed'); + if (completedRaces.length === 0) continue; + + const steward = members[0]!; + const targetDriver = members[1]!; + + // Create one penalty for each type/status combination + let penaltyIndex = 0; + for (const type of penaltyTypes) { + for (const status of penaltyStatuses) { + // Skip some combinations to avoid too many records + if (faker.number.int({ min: 0, max: 2 }) > 0) continue; + + const race = faker.helpers.arrayElement(completedRaces); + const value = type === 'time_penalty' ? faker.number.int({ min: 5, max: 30 }) : + type === 'grid_penalty' ? faker.number.int({ min: 1, max: 5 }) : + type === 'points_deduction' ? faker.number.int({ min: 2, max: 10 }) : + type === 'license_points' ? faker.number.int({ min: 1, max: 4 }) : + type === 'fine' ? faker.number.int({ min: 50, max: 500 }) : + type === 'race_ban' ? faker.number.int({ min: 1, max: 3 }) : + type === 'warning' ? 1 : + 1; // disqualification, probation have no value + + const penaltyData: { + id: string; + leagueId: string; + raceId: string; + driverId: string; + type: typeof penaltyTypes[number]; + value?: number; + reason: string; + issuedBy: string; + status: typeof penaltyStatuses[number]; + issuedAt: Date; + appliedAt?: Date; + notes?: string; + } = { + id: seedId(`penalty-${leagueId}-${type}-${status}-${penaltyIndex}`, this.persistence), + leagueId, + raceId: race.id.toString(), + driverId: targetDriver, + type, + reason: this.getPenaltyReason(type), + issuedBy: steward, + status, + issuedAt: faker.date.recent({ days: faker.number.int({ min: 1, max: 30 }), refDate: this.baseDate }), + }; + + // Add value only for types that require it + if (type !== 'warning') { + penaltyData.value = value; + } + + if (status === 'applied') { + penaltyData.appliedAt = faker.date.recent({ days: faker.number.int({ min: 1, max: 20 }), refDate: this.baseDate }); + } + + if (type === 'race_ban') { + penaltyData.notes = 'Multiple serious violations'; + } + + penalties.push(Penalty.create(penaltyData)); + penaltyIndex++; + } + } + } + return { protests, penalties }; } + + private getPenaltyReason(type: string): string { + const reasons = { + time_penalty: ['Avoidable contact', 'Track limits abuse', 'Unsafe rejoin', 'Blocking'], + grid_penalty: ['Qualifying infringement', 'Parc fermé violation', 'Practice session breach'], + points_deduction: ['Serious breach of rules', 'Multiple incidents', 'Unsportsmanlike conduct'], + disqualification: ['Severe dangerous driving', 'Gross misconduct', 'Multiple serious violations'], + warning: ['Track limits reminder', 'Minor contact', 'Procedure reminder'], + license_points: ['General misconduct', 'Minor incidents', 'Warning escalation'], + probation: ['Pattern of minor violations', 'Behavioral concerns', 'Conditional status'], + fine: ['Financial penalty for rule breach', 'Administrative violation', 'Late entry fee'], + race_ban: ['Multiple race bans', 'Severe dangerous driving', 'Gross misconduct'], + }; + return faker.helpers.arrayElement(reasons[type as keyof typeof reasons] || ['Rule violation']); + } } \ No newline at end of file diff --git a/adapters/bootstrap/racing/RacingTeamFactory.ts b/adapters/bootstrap/racing/RacingTeamFactory.ts index 7cd7e1111..9547dbdc5 100644 --- a/adapters/bootstrap/racing/RacingTeamFactory.ts +++ b/adapters/bootstrap/racing/RacingTeamFactory.ts @@ -5,6 +5,17 @@ import type { TeamJoinRequest, TeamMembership } from '@core/racing/domain/types/ import { faker } from '@faker-js/faker'; import { seedId } from './SeedIdHelper'; +export interface TeamStats { + logoUrl: string; + performanceLevel: 'beginner' | 'intermediate' | 'advanced' | 'pro'; + specialization: 'endurance' | 'sprint' | 'mixed'; + region: string; + languages: string[]; + totalWins: number; + totalRaces: number; + rating: number; +} + export class RacingTeamFactory { constructor( private readonly baseDate: Date, @@ -12,7 +23,7 @@ export class RacingTeamFactory { ) {} createTeams(drivers: Driver[], leagues: League[]): Team[] { - const teamCount = 15; + const teamCount = 50; // Increased from 15 to 50 return Array.from({ length: teamCount }, (_, idx) => { const i = idx + 1; @@ -177,8 +188,98 @@ export class RacingTeamFactory { return requests; } + /** + * Generate team statistics and metadata for display + * This would be stored in a separate stats table/service in production + */ + generateTeamStats(teams: Team[]): Map { + const statsMap = new Map(); + + // Available logo URLs (simulating media uploads) + const logoUrls = [ + '/images/ff1600.jpeg', + '/images/header.jpeg', + '/images/avatars/male-default-avatar.jpg', + '/images/avatars/female-default-avatar.jpeg', + '/images/avatars/neutral-default-avatar.jpeg', + '/images/leagues/placeholder-cover.svg', + ]; + + // Available regions + const regions = ['Europe', 'North America', 'South America', 'Asia', 'Oceania', 'Africa']; + + // Available languages + const allLanguages = ['English', 'German', 'French', 'Spanish', 'Italian', 'Portuguese', 'Japanese', 'Korean', 'Russian', 'Chinese']; + + teams.forEach((team, idx) => { + const i = idx + 1; + + // Determine performance level based on index + let performanceLevel: 'beginner' | 'intermediate' | 'advanced' | 'pro'; + let totalRaces: number; + let rating: number; + let totalWins: number; + + if (i % 8 === 0) { + // Pro teams + performanceLevel = 'pro'; + totalRaces = faker.number.int({ min: 80, max: 150 }); + rating = faker.number.int({ min: 1700, max: 2000 }); + totalWins = faker.number.int({ min: 15, max: 40 }); + } else if (i % 5 === 0) { + // Advanced teams + performanceLevel = 'advanced'; + totalRaces = faker.number.int({ min: 40, max: 100 }); + rating = faker.number.int({ min: 1500, max: 1800 }); + totalWins = faker.number.int({ min: 8, max: 20 }); + } else if (i % 3 === 0) { + // Intermediate teams + performanceLevel = 'intermediate'; + totalRaces = faker.number.int({ min: 20, max: 60 }); + rating = faker.number.int({ min: 1200, max: 1600 }); + totalWins = faker.number.int({ min: 3, max: 12 }); + } else { + // Beginner teams + performanceLevel = 'beginner'; + totalRaces = faker.number.int({ min: 5, max: 25 }); + rating = faker.number.int({ min: 900, max: 1300 }); + totalWins = faker.number.int({ min: 0, max: 5 }); + } + + // Determine specialization + let specialization: 'endurance' | 'sprint' | 'mixed'; + if (i % 7 === 0) { + specialization = 'endurance'; + } else if (i % 4 === 0) { + specialization = 'sprint'; + } else { + specialization = 'mixed'; + } + + // Generate region and languages + const region = faker.helpers.arrayElement(regions); + const languageCount = faker.number.int({ min: 1, max: 3 }); + const languages = faker.helpers.arrayElements(allLanguages, languageCount); + + // Generate logo URL (varied) + const logoUrl = logoUrls[i % logoUrls.length] ?? logoUrls[0]; + + statsMap.set(team.id.toString(), { + logoUrl: logoUrl!, + performanceLevel, + specialization, + region, + languages, + totalWins, + totalRaces, + rating, + }); + }); + + return statsMap; + } + private addDays(date: Date, days: number): Date { return new Date(date.getTime() + days * 24 * 60 * 60 * 1000); } - } \ No newline at end of file diff --git a/adapters/media/ports/InMemoryImageServiceAdapter.ts b/adapters/media/ports/InMemoryImageServiceAdapter.ts index 40e03aec9..42955590b 100644 --- a/adapters/media/ports/InMemoryImageServiceAdapter.ts +++ b/adapters/media/ports/InMemoryImageServiceAdapter.ts @@ -22,7 +22,19 @@ export class InMemoryImageServiceAdapter implements IImageServicePort { getTeamLogo(teamId: string): string { this.logger.debug(`[InMemoryImageServiceAdapter] Getting logo for team: ${teamId}`); - return '/images/ff1600.jpeg'; + const teamNumber = Number(teamId.replace('team-', '')); + const index = Number.isFinite(teamNumber) ? teamNumber % 6 : 0; + + const logos = [ + '/images/ff1600.jpeg', + '/images/header.jpeg', + '/images/avatars/male-default-avatar.jpg', + '/images/avatars/female-default-avatar.jpeg', + '/images/avatars/neutral-default-avatar.jpeg', + '/images/leagues/placeholder-cover.svg', + ] as const; + + return logos[index] ?? logos[0]; } getLeagueCover(leagueId: string): string { @@ -34,4 +46,4 @@ export class InMemoryImageServiceAdapter implements IImageServicePort { this.logger.debug(`[InMemoryImageServiceAdapter] Getting logo for league: ${leagueId}`); return '/images/ff1600.jpeg'; } -} +} \ No newline at end of file diff --git a/adapters/racing/services/DriverStatsStore.ts b/adapters/racing/services/DriverStatsStore.ts new file mode 100644 index 000000000..06dfbd9c7 --- /dev/null +++ b/adapters/racing/services/DriverStatsStore.ts @@ -0,0 +1,50 @@ +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(); + + 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): 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 { + return new Map(this.statsMap); + } +} \ No newline at end of file diff --git a/adapters/racing/services/InMemoryDriverStatsService.ts b/adapters/racing/services/InMemoryDriverStatsService.ts index 263e4772b..24d6de113 100644 --- a/adapters/racing/services/InMemoryDriverStatsService.ts +++ b/adapters/racing/services/InMemoryDriverStatsService.ts @@ -1,33 +1,17 @@ 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}`); - - // Mock data for demonstration purposes - if (driverId === 'driver-1') { - return { - rating: 2500, - wins: 10, - podiums: 15, - totalRaces: 50, - overallRank: 1, - }; - } - if (driverId === 'driver-2') { - return { - rating: 2400, - wins: 8, - podiums: 12, - totalRaces: 45, - overallRank: 2, - }; - } - return null; + return this.store.getDriverStats(driverId); } -} +} \ No newline at end of file diff --git a/adapters/racing/services/InMemoryRankingService.ts b/adapters/racing/services/InMemoryRankingService.ts index f567f0bfa..8c213e309 100644 --- a/adapters/racing/services/InMemoryRankingService.ts +++ b/adapters/racing/services/InMemoryRankingService.ts @@ -1,5 +1,6 @@ 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) { @@ -9,12 +10,29 @@ export class InMemoryRankingService implements IRankingService { getAllDriverRankings(): DriverRanking[] { this.logger.debug('[InMemoryRankingService] Getting all driver rankings.'); - // Mock data for demonstration purposes - const mockRankings: DriverRanking[] = [ - { driverId: 'driver-1', rating: 2500, overallRank: 1 }, - { driverId: 'driver-2', rating: 2400, overallRank: 2 }, - { driverId: 'driver-3', rating: 2300, overallRank: 3 }, - ]; - return mockRankings; + // 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; } } diff --git a/adapters/racing/services/TeamStatsStore.ts b/adapters/racing/services/TeamStatsStore.ts new file mode 100644 index 000000000..fe7442111 --- /dev/null +++ b/adapters/racing/services/TeamStatsStore.ts @@ -0,0 +1,50 @@ +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(); + + 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): 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 { + return new Map(this.statsMap); + } +} \ No newline at end of file diff --git a/apps/api/src/domain/bootstrap/BootstrapModule.ts b/apps/api/src/domain/bootstrap/BootstrapModule.ts index d0fca9d13..fad4fa212 100644 --- a/apps/api/src/domain/bootstrap/BootstrapModule.ts +++ b/apps/api/src/domain/bootstrap/BootstrapModule.ts @@ -2,7 +2,7 @@ import type { Logger } from '@core/shared/application'; import type { EnsureInitialData } from '../../../../../adapters/bootstrap/EnsureInitialData'; import { SeedRacingData, type RacingSeedDependencies } from '../../../../../adapters/bootstrap/SeedRacingData'; import { Inject, Module, OnModuleInit } from '@nestjs/common'; -import { getApiPersistence, getEnableBootstrap } from '../../env'; +import { getApiPersistence, getEnableBootstrap, getForceReseed } from '../../env'; import { RacingPersistenceModule } from '../../persistence/racing/RacingPersistenceModule'; import { SocialPersistenceModule } from '../../persistence/social/SocialPersistenceModule'; import { AchievementPersistenceModule } from '../../persistence/achievement/AchievementPersistenceModule'; @@ -48,7 +48,21 @@ export class BootstrapModule implements OnModuleInit { if (persistence !== 'postgres') return false; if (process.env.NODE_ENV === 'production') return false; - return this.isRacingDatabaseEmpty(); + // Check for force reseed flag + const forceReseed = getForceReseed(); + if (forceReseed) { + this.logger.info('[Bootstrap] Force reseed enabled via GRIDPILOT_API_FORCE_RESEED'); + return true; + } + + // Check if database is empty + const isEmpty = await this.isRacingDatabaseEmpty(); + if (!isEmpty) { + // Database has data, check if it needs reseeding + return await this.needsReseed(); + } + + return true; } private async isRacingDatabaseEmpty(): Promise { @@ -58,4 +72,24 @@ export class BootstrapModule implements OnModuleInit { const leagues = await this.seedDeps.leagueRepository.findAll(); return leagues.length === 0; } + + private async needsReseed(): Promise { + // Check if driver count is less than expected (150) + // This indicates old seed data that needs updating + try { + const drivers = await this.seedDeps.driverRepository.findAll(); + const driverCount = drivers.length; + + // If we have fewer than 150 drivers, we need to reseed + if (driverCount < 150) { + this.logger.info(`[Bootstrap] Found ${driverCount} drivers (expected 150), triggering reseed`); + return true; + } + + return false; + } catch (error) { + this.logger.warn('[Bootstrap] Error checking driver count for reseed:', error); + return false; + } + } } \ No newline at end of file diff --git a/apps/api/src/domain/driver/dtos/GetDriverOutputDTO.ts b/apps/api/src/domain/driver/dtos/GetDriverOutputDTO.ts index 9cb42b610..42ec0a38d 100644 --- a/apps/api/src/domain/driver/dtos/GetDriverOutputDTO.ts +++ b/apps/api/src/domain/driver/dtos/GetDriverOutputDTO.ts @@ -18,4 +18,19 @@ export class GetDriverOutputDTO { @ApiProperty() joinedAt!: string; + + @ApiProperty({ required: false }) + rating?: number; + + @ApiProperty({ required: false }) + experienceLevel?: string; + + @ApiProperty({ required: false }) + wins?: number; + + @ApiProperty({ required: false }) + podiums?: number; + + @ApiProperty({ required: false }) + totalRaces?: number; } \ No newline at end of file diff --git a/apps/api/src/domain/driver/presenters/DriverPresenter.ts b/apps/api/src/domain/driver/presenters/DriverPresenter.ts index 1a0bbe403..2ec3684b1 100644 --- a/apps/api/src/domain/driver/presenters/DriverPresenter.ts +++ b/apps/api/src/domain/driver/presenters/DriverPresenter.ts @@ -1,6 +1,7 @@ import { Result } from '@core/shared/application/Result'; import type { Driver } from '@core/racing/domain/entities/Driver'; import type { GetDriverOutputDTO } from '../dtos/GetDriverOutputDTO'; +import { DriverStatsStore } from '@adapters/racing/services/DriverStatsStore'; export class DriverPresenter { private responseModel: GetDriverOutputDTO | null = null; @@ -18,6 +19,10 @@ export class DriverPresenter { return; } + // Get stats from the store + const statsStore = DriverStatsStore.getInstance(); + const stats = statsStore.getDriverStats(driver.id); + this.responseModel = { id: driver.id, iracingId: driver.iracingId.toString(), @@ -25,10 +30,25 @@ export class DriverPresenter { country: driver.country.toString(), joinedAt: driver.joinedAt.toDate().toISOString(), ...(driver.bio ? { bio: driver.bio.toString() } : {}), + // Add stats fields + ...(stats ? { + rating: stats.rating, + wins: stats.wins, + podiums: stats.podiums, + totalRaces: stats.totalRaces, + experienceLevel: this.getExperienceLevel(stats.rating), + } : {}), }; } getResponseModel(): GetDriverOutputDTO | null { return this.responseModel; } + + private getExperienceLevel(rating: number): string { + if (rating >= 1700) return 'veteran'; + if (rating >= 1300) return 'advanced'; + if (rating >= 1000) return 'intermediate'; + return 'beginner'; + } } diff --git a/apps/api/src/domain/team/dtos/TeamListItemDTO.ts b/apps/api/src/domain/team/dtos/TeamListItemDTO.ts index c758ebb76..76df04d27 100644 --- a/apps/api/src/domain/team/dtos/TeamListItemDTO.ts +++ b/apps/api/src/domain/team/dtos/TeamListItemDTO.ts @@ -27,5 +27,20 @@ export class TeamListItemDTO { @ApiProperty({ type: [String], required: false }) languages?: string[]; + + @ApiProperty({ required: false }) + totalWins?: number; + + @ApiProperty({ required: false }) + totalRaces?: number; + + @ApiProperty({ required: false, enum: ['beginner', 'intermediate', 'advanced', 'pro'] }) + performanceLevel?: 'beginner' | 'intermediate' | 'advanced' | 'pro'; + + @ApiProperty({ required: false }) + logoUrl?: string; + + @ApiProperty({ required: false }) + rating?: number; } diff --git a/apps/api/src/domain/team/presenters/AllTeamsPresenter.ts b/apps/api/src/domain/team/presenters/AllTeamsPresenter.ts index 066d64aed..8cdf3d554 100644 --- a/apps/api/src/domain/team/presenters/AllTeamsPresenter.ts +++ b/apps/api/src/domain/team/presenters/AllTeamsPresenter.ts @@ -1,6 +1,7 @@ import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; import type { GetAllTeamsResult } from '@core/racing/application/use-cases/GetAllTeamsUseCase'; import { GetAllTeamsOutputDTO } from '../dtos/GetAllTeamsOutputDTO'; +import { TeamStatsStore } from '@adapters/racing/services/TeamStatsStore'; export class AllTeamsPresenter implements UseCaseOutputPort { private model: GetAllTeamsOutputDTO | null = null; @@ -10,16 +11,41 @@ export class AllTeamsPresenter implements UseCaseOutputPort { } present(result: GetAllTeamsResult): void { + const statsStore = TeamStatsStore.getInstance(); + this.model = { - teams: result.teams.map(team => ({ - id: team.id, - name: team.name.toString(), - tag: team.tag.toString(), - description: team.description?.toString() || '', - memberCount: team.memberCount, - leagues: team.leagues?.map(l => l.toString()) || [], - // Note: specialization, region, languages not available in output - })), + teams: result.teams.map(team => { + const stats = statsStore.getTeamStats(team.id.toString()); + + return { + id: team.id, + name: team.name.toString(), + tag: team.tag.toString(), + description: team.description?.toString() || '', + memberCount: team.memberCount, + leagues: team.leagues?.map(l => l.toString()) || [], + // Add stats fields + ...(stats ? { + totalWins: stats.totalWins, + totalRaces: stats.totalRaces, + performanceLevel: stats.performanceLevel, + specialization: stats.specialization, + region: stats.region, + languages: stats.languages, + logoUrl: stats.logoUrl, + rating: stats.rating, + } : { + totalWins: 0, + totalRaces: 0, + performanceLevel: 'beginner', + specialization: 'mixed', + region: '', + languages: [], + logoUrl: '', + rating: 0, + }), + }; + }), totalCount: result.totalCount ?? result.teams.length, }; } diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 64844d6b1..1b7978701 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -57,6 +57,21 @@ export function getEnableBootstrap(): boolean { return isTruthyEnv(raw); } +/** + * Force reseeding of racing data in development mode. + * + * `GRIDPILOT_API_FORCE_RESEED` uses "truthy" parsing: + * - false when unset / "0" / "false" + * - true otherwise + * + * Only works in non-production environments. + */ +export function getForceReseed(): boolean { + const raw = process.env.GRIDPILOT_API_FORCE_RESEED; + if (raw === undefined) return false; + return isTruthyEnv(raw); +} + /** * When set, the API will generate `openapi.json` and optionally reduce logging noise. * diff --git a/plans/seeds-plan.md b/plans/seeds-plan.md new file mode 100644 index 000000000..2875068d7 --- /dev/null +++ b/plans/seeds-plan.md @@ -0,0 +1,115 @@ +# Comprehensive Seeding Plan for GridPilot + +## Current Seeding Setup +Current seeding in [`adapters/bootstrap`](adapters/bootstrap) includes: +- `EnsureInitialData.ts`: Creates admin user (`admin@gridpilot.local` / `admin123`) and all achievements (driver, steward, admin, community). +- `SeedRacingData.ts`: If no drivers exist, seeds ~100 drivers, 20 leagues, seasons, teams, races, results, standings, memberships, join requests, protests, penalties, sponsors, wallets/transactions, social feed/friendships using factories in `bootstrap/racing/`. + +Seeding skips if data exists (idempotent), uses seed IDs for determinism (e.g., `seedId('league-1', persistence)`). + +Persistence-aware (inmemory/postgres), ensures scoring configs for existing data. + +## Identified Entities +From `core/*/domain/entities/` and factories/repositories: + +### Identity Domain +- [`User`](core/identity/domain/entities/User.ts) +- [`Achievement`](core/identity/domain/entities/Achievement.ts) +- [`UserAchievement`](core/identity/domain/entities/UserAchievement.ts) +- [`SponsorAccount`](core/identity/domain/entities/SponsorAccount.ts) +- [`ExternalGameRatingProfile`](core/identity/domain/entities/ExternalGameRatingProfile.ts) +- [`RatingEvent`](core/identity/domain/entities/RatingEvent.ts) +- [`AdminVoteSession`](core/identity/domain/entities/AdminVoteSession.ts) + +### Racing Domain (primary, most seeded) +- [`Driver`](core/racing/domain/entities/Driver.ts): id, iracingId, name, country, bio?, joinedAt +- [`League`](core/racing/domain/entities/League.ts): id, name, description, ownerId, settings (pointsSystem enum ['f1-2024','indycar','custom'], sessionDuration, qualifyingFormat enum, maxDrivers, visibility enum ['ranked','unranked'], stewarding config), createdAt, socialLinks?, participantCount (0-max) +- [`Season`](core/racing/domain/entities/season/Season.ts): id, leagueId, gameId, name, year?, order?, status enum ['planned','active','completed','archived','cancelled'], start/endDate?, schedule?, schedulePublished bool, scoringConfig?, dropPolicy?, stewardingConfig?, maxDrivers?, participantCount +- [`Team`](core/racing/domain/entities/Team.ts): id, name, tag, description, ownerId (DriverId), leagues[], createdAt +- [`Standing`](core/racing/domain/entities/Standing.ts): id (leagueId:driverId), leagueId, driverId, points, wins, position, racesCompleted +- Race-related: [`Race`](core/racing/domain/entities/Race.ts), [`RaceEvent`](core/racing/domain/entities/RaceEvent.ts), [`Session`](core/racing/domain/entities/Session.ts), [`Result`](core/racing/domain/entities/result/Result.ts), [`RaceRegistration`](core/racing/domain/entities/RaceRegistration.ts) +- Stewarding: [`Protest`](core/racing/domain/entities/Protest.ts) (statuses), [`Penalty`](core/racing/domain/entities/penalty/Penalty.ts) (types enum, status) +- Other: [`JoinRequest`](core/racing/domain/entities/JoinRequest.ts), [`LeagueMembership`](core/racing/domain/entities/LeagueMembership.ts), [`Sponsor`](core/racing/domain/entities/sponsor/Sponsor.ts), [`SeasonSponsorship`](core/racing/domain/entities/season/SeasonSponsorship.ts), [`LeagueWallet`](core/racing/domain/entities/league-wallet/LeagueWallet.ts), [`Transaction`](core/racing/domain/entities/league-wallet/Transaction.ts), Track, Car, etc. +- ChampionshipStanding + +### Other Domains +- Analytics: AnalyticsSnapshot, EngagementEvent, PageView +- Media: Avatar, AvatarGenerationRequest, Media +- Notifications: Notification, NotificationPreference +- Payments: MemberPayment, MembershipFee, Payment, Prize, Wallet + +## Seeding Strategy +**Goal**: Cover **every valid state/combination** for testing all validations, transitions, queries, UIs. +- Use factories extending current `Racing*Factory` pattern. +- Deterministic IDs via `seedId(name, persistence)`. +- Group by domain, vary enums/states systematically. +- Relations: Create minimal graphs covering with/without relations. +- Volume: 5-20 examples per entity, prioritizing edge cases (min/max, empty/full, all enum values). +- Idempotent: Check existence before create. + +### 1. Identity Seeding +**User**: +- Fields: id, displayName (1-50 chars), email (valid/invalid? but valid), passwordHash, iracingCustomerId?, primaryDriverId?, avatarUrl? +- States: 5 users - no email, with iRacing linked, with primaryDriver, admin@gridpilot.local (existing), verified/unverified (if state). +- Examples: + - Admin: id='user-admin', displayName='Admin', email='admin@gridpilot.local' + - Driver1: id='driver-1', displayName='Max Verstappen', iracingCustomerId='12345', primaryDriverId='driver-1' + - Sponsor: displayName='Sponsor Inc', email='sponsor@example.com' + +**Achievement** etc.: All constants already seeded, add user-achievements linking users to achievements. + +### 2. Racing Seeding (extend current) +**Driver** (100+): +- Fields: id, iracingId (unique), name, country (ISO), bio?, joinedAt +- States: 20 countries, bio empty/full, recent/past joined. +- Relations: Link to users, teams. + +**League** (20+): +- Fields/Constraints: name(3-100), desc(10-500), ownerId (valid Driver), settings.pointsSystem (3 enums), sessionDuration(15-240min), qualifyingFormat(2), maxDrivers(10-60), visibility(2, ranked min10 participants?), stewarding.decisionMode(6 enums), requiredVotes(1-10), timeLimits(1-168h), participantCount(0-max) +- All combos: 3 points x 2 qual x 2 vis x 6 decision = ~72, but sample 20 covering extremes. +- States: + - Empty new league (participantCount=0) + - Full ranked (maxDrivers=40, count=40) + - Unranked small (max=8, count=5) + - Various stewarding (admin_only, steward_vote req=3, etc.) +- Examples: + - `league-1`: ranked, f1-2024, max40, participant20, steward_vote req3 + - `league-empty`: unranked, custom, max10, count0 + +**Season** (per league 2-3): +- Fields: status(5), schedule pub Y/N, scoring/drop/stewarding present/absent, participantCount 0-max +- States: All status transitions valid, planned no dates, active mid, completed full schedule, cancelled early, archived old. +- Combos: 5 status x 2 pub x 3 configs present = 30+ + +**Standing**: +- position 1-60, points 0-high, wins 0-totalRaces, racesCompleted 0-total + +**Protest/Penalty**: +- ProtestStatus enum (filed, defended, voted, decided...), IncidentDescription, etc. +- PenaltyType (time, positionDrop, pointsDeduct, ban), status (pending,applied) + +**Relations**: +- Memberships: pending/active/banned roles (owner,driver,steward) +- JoinRequests: pending/approved/rejected +- Races: scheduled/running/completed/cancelled, registrations full/partial +- Results: all positions, incidents 0-high +- Teams: 0-N drivers, join requests +- Sponsors: active/pending, requests pending/accepted/rejected +- Wallets: balance 0+, transactions deposit/withdraw + +### 3. Other Domains +**Media/Notifications/Analytics/Payments**: Minimal graphs linking to users/drivers/leagues (e.g. avatars for drivers, notifications for joins, pageviews for leagues, payments for memberships). + +## Proposed Seed Data Volume +- Identity: 10 users, 50 achievements+links +- Racing: 150 drivers, 30 leagues (all settings combos), 100 seasons (all status), 50 teams, 500 standings/results, 100 protests/penalties, full relation graphs +- Other: 50 each +- **Total ~2000 records**, covering 100% valid states. + +## Implementation Steps (for Code mode) +1. Extend factories for new states/combos. +2. Add factories for non-racing entities. +3. Update SeedRacingData to call all. +4. EnsureInitialData for non-racing. + +This plan covers **every single possible valid state** via systematic enum cartesian + edges (0/max/empty/full/pending/complete)."