seed data

This commit is contained in:
2025-12-30 00:15:35 +01:00
parent 7a853d4e43
commit ccaa39c39c
22 changed files with 1342 additions and 173 deletions

View File

@@ -15,6 +15,9 @@ NEXT_TELEMETRY_DISABLED=1
# API persistence is inferred from DATABASE_URL by default. # API persistence is inferred from DATABASE_URL by default.
# GRIDPILOT_API_PERSISTENCE=postgres # GRIDPILOT_API_PERSISTENCE=postgres
# Force reseed on every startup in development
GRIDPILOT_API_FORCE_RESEED=true
DATABASE_URL=postgres://gridpilot_user:gridpilot_dev_pass@db:5432/gridpilot_dev DATABASE_URL=postgres://gridpilot_user:gridpilot_dev_pass@db:5432/gridpilot_dev
# Postgres container vars (used by `docker-compose.dev.yml` -> `db`) # Postgres container vars (used by `docker-compose.dev.yml` -> `db`)

View File

@@ -61,5 +61,61 @@ export class EnsureInitialData {
} }
this.logger.info(`[Bootstrap] Achievements: ${createdCount} created, ${existingCount} already exist`); 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<void> {
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<void> {
// 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)');
} }
} }

View File

@@ -22,8 +22,9 @@ import type { IPenaltyRepository } from '@core/racing/domain/repositories/IPenal
import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository'; import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository';
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository'; import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
import { createRacingSeed } from './racing/RacingSeed'; import { createRacingSeed } from './racing/RacingSeed';
import { getApiPersistence } from '../../apps/api/src/env';
import { seedId } from './racing/SeedIdHelper'; import { seedId } from './racing/SeedIdHelper';
import { DriverStatsStore } from '@adapters/racing/services/DriverStatsStore';
import { TeamStatsStore } from '@adapters/racing/services/TeamStatsStore';
export type RacingSeedDependencies = { export type RacingSeedDependencies = {
driverRepository: IDriverRepository; driverRepository: IDriverRepository;
@@ -54,16 +55,56 @@ export class SeedRacingData {
private readonly seedDeps: RacingSeedDependencies, 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> { async execute(): Promise<void> {
const existingDrivers = await this.seedDeps.driverRepository.findAll(); 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'); this.logger.info('[Bootstrap] Racing seed skipped (drivers already exist), ensuring scoring configs');
await this.ensureScoringConfigsForExistingData(); await this.ensureScoringConfigsForExistingData();
return; return;
} }
const persistence = getApiPersistence(); if (forceReseed && existingDrivers.length > 0) {
const seed = createRacingSeed({ persistence }); 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; let sponsorshipRequestsSeededViaRepo = false;
const seedableSponsorshipRequests = this.seedDeps const seedableSponsorshipRequests = this.seedDeps
@@ -268,6 +309,36 @@ export class SeedRacingData {
); );
} }
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> { private async ensureScoringConfigsForExistingData(): Promise<void> {
const leagues = await this.seedDeps.leagueRepository.findAll(); const leagues = await this.seedDeps.leagueRepository.findAll();

View File

@@ -2,6 +2,22 @@ import { Driver } from '@core/racing/domain/entities/Driver';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import { seedId } from './SeedIdHelper'; 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 { export class RacingDriverFactory {
constructor( constructor(
private readonly driverCount: number, private readonly driverCount: number,
@@ -15,14 +31,156 @@ export class RacingDriverFactory {
return Array.from({ length: this.driverCount }, (_, idx) => { return Array.from({ length: this.driverCount }, (_, idx) => {
const i = idx + 1; 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), id: seedId(`driver-${i}`, this.persistence),
iracingId: String(100000 + i), iracingId: String(100000 + i),
name: faker.person.fullName(), name: faker.person.fullName(),
country: faker.helpers.arrayElement(countries), country: faker.helpers.arrayElement(countries),
bio: faker.lorem.sentences(2), joinedAt,
joinedAt: faker.date.past({ years: 2, refDate: this.baseDate }), };
});
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<string, DriverStats> {
const statsMap = new Map<string, DriverStats>();
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;
}
} }

View File

@@ -11,29 +11,273 @@ export class RacingLeagueFactory {
) {} ) {}
create(): League[] { 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 = [ const leagueConfigs = [
// Small sprint leagues // 1-5: Ranked, F1-2024, various stewarding
{ 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 }, pointsSystem: 'f1-2024' as const,
// Medium endurance leagues qualifyingFormat: 'single-lap' as const,
{ maxDrivers: 24, sessionDuration: 60, pointsSystem: 'indycar' as const, qualifyingFormat: 'open' as const }, visibility: 'ranked' as const,
{ maxDrivers: 28, sessionDuration: 90, pointsSystem: 'custom' as const, qualifyingFormat: 'open' as const }, maxDrivers: 40,
// Large mixed leagues sessionDuration: 60,
{ maxDrivers: 32, sessionDuration: 120, pointsSystem: 'f1-2024' as const, qualifyingFormat: 'open' as const }, stewarding: { decisionMode: 'admin_only' as const, requireDefense: false }
{ 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 }, pointsSystem: 'f1-2024' as const,
{ maxDrivers: 48, sessionDuration: 110, pointsSystem: 'indycar' as const, qualifyingFormat: 'single-lap' as const }, qualifyingFormat: 'open' as const,
{ maxDrivers: 50, sessionDuration: 95, pointsSystem: 'custom' 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) => { return Array.from({ length: leagueCount }, (_, idx) => {
const i = idx + 1; const i = idx + 1;
const owner = faker.helpers.arrayElement(this.drivers); const owner = faker.helpers.arrayElement(this.drivers);
const config = leagueConfigs[idx % leagueConfigs.length]!; const config = leagueConfigs[idx]!;
const createdAt = const createdAt =
// Ensure some "New" leagues (created within 7 days) so `/leagues` featured categories are populated. // 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.recent({ days: 6, refDate: this.baseDate })
: faker.date.past({ years: 2, 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: { const leagueData: {
id: string; id: string;
name: string; name: string;
@@ -51,6 +320,18 @@ export class RacingLeagueFactory {
maxDrivers: number; maxDrivers: number;
sessionDuration: number; sessionDuration: number;
qualifyingFormat: 'open' | 'single-lap'; 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; createdAt: Date;
socialLinks?: { discordUrl?: string; youtubeUrl?: string; websiteUrl?: string }; socialLinks?: { discordUrl?: string; youtubeUrl?: string; websiteUrl?: string };
@@ -60,11 +341,23 @@ export class RacingLeagueFactory {
name: faker.company.name() + ' Racing League', name: faker.company.name() + ' Racing League',
description: faker.lorem.sentences(2), description: faker.lorem.sentences(2),
ownerId: owner.id.toString(), 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, createdAt,
// Start with some participants for ranked leagues to meet minimum requirements participantCount,
// Note: ranked leagues require >= 10 participants (see LeagueVisibility)
participantCount: i % 3 === 0 ? 12 : 0,
}; };
// Add social links with varying completeness // Add social links with varying completeness

View File

@@ -21,7 +21,7 @@ export class RacingRaceFactory {
// Create races with systematic coverage of different statuses and scenarios // Create races with systematic coverage of different statuses and scenarios
const statuses: Array<'scheduled' | 'running' | 'completed' | 'cancelled'> = ['scheduled', 'running', 'completed', 'cancelled']; 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 leagueId = leagueIds[(i - 1) % leagueIds.length] ?? demoLeagueId;
const trackId = trackIds[(i - 1) % trackIds.length]!; const trackId = trackIds[(i - 1) % trackIds.length]!;
const track = tracks.find(t => t.id === trackId)!; const track = tracks.find(t => t.id === trackId)!;

View File

@@ -19,111 +19,143 @@ export class RacingSeasonSponsorshipFactory {
for (const league of leagues) { for (const league of leagues) {
const leagueId = league.id.toString(); const leagueId = league.id.toString();
const leagueIndex = parseInt(leagueId.split('-')[1] || '0');
if (leagueId === seedId('league-5', this.persistence)) { // Create 2-4 seasons per league to reach ~100 total seasons
seasons.push( const seasonCount = faker.number.int({ min: 2, max: 4 });
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 });
for (let i = 0; i < seasonCount; i++) { for (let i = 0; i < seasonCount; i++) {
const id = seedId(`${leagueId}-season-${i + 1}`, this.persistence); 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 = const baseYear = this.baseDate.getUTCFullYear() + faker.number.int({ min: -1, max: 1 });
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 startOffset = // Calculate dates based on status
status === 'active' let startDate: Date | undefined;
? faker.number.int({ min: -60, max: -1 }) let endDate: Date | undefined;
: status === 'planned' let schedulePublished: boolean | undefined;
? faker.number.int({ min: 7, max: 60 }) let participantCount: number | undefined;
: faker.number.int({ min: -200, max: -90 }); let maxDrivers: number | undefined;
const endOffset = switch (status) {
status === 'completed' || status === 'archived' || status === 'cancelled' case 'planned':
? faker.number.int({ min: -89, max: -7 }) startDate = this.daysFromBase(faker.number.int({ min: 7, max: 90 }));
: undefined; schedulePublished = faker.datatype.boolean({ probability: 0.6 });
participantCount = 0;
break;
seasons.push( case 'active':
Season.create({ startDate = this.daysFromBase(faker.number.int({ min: -60, max: -1 }));
id, schedulePublished = true;
leagueId, maxDrivers = faker.number.int({
gameId: 'iracing', min: 10,
name: `${faker.word.adjective()} ${faker.word.noun()} Season`, max: Math.max(10, Math.min(league.settings.maxDrivers || 32, 100))
year: baseYear + faker.number.int({ min: -1, max: 1 }), });
order: i + 1, participantCount = faker.number.int({ min: 5, max: maxDrivers });
status, break;
startDate: this.daysFromBase(startOffset),
...(endOffset !== undefined ? { endDate: this.daysFromBase(endOffset) } : {}), 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);
} }
} }

View File

@@ -14,7 +14,7 @@ import { Season } from '@core/racing/domain/entities/season/Season';
import { SeasonSponsorship } from '@core/racing/domain/entities/season/SeasonSponsorship'; import { SeasonSponsorship } from '@core/racing/domain/entities/season/SeasonSponsorship';
import type { TeamJoinRequest, TeamMembership } from '@core/racing/domain/types/TeamMembership'; import type { TeamJoinRequest, TeamMembership } from '@core/racing/domain/types/TeamMembership';
import type { FeedItem } from '@core/social/domain/types/FeedItem'; import type { FeedItem } from '@core/social/domain/types/FeedItem';
import { RacingDriverFactory } from './RacingDriverFactory'; import { RacingDriverFactory, type DriverStats } from './RacingDriverFactory';
import { RacingFeedFactory } from './RacingFeedFactory'; import { RacingFeedFactory } from './RacingFeedFactory';
import { RacingFriendshipFactory } from './RacingFriendshipFactory'; import { RacingFriendshipFactory } from './RacingFriendshipFactory';
import { RacingLeagueFactory } from './RacingLeagueFactory'; import { RacingLeagueFactory } from './RacingLeagueFactory';
@@ -22,7 +22,7 @@ import { RacingMembershipFactory } from './RacingMembershipFactory';
import { RacingRaceFactory } from './RacingRaceFactory'; import { RacingRaceFactory } from './RacingRaceFactory';
import { RacingResultFactory } from './RacingResultFactory'; import { RacingResultFactory } from './RacingResultFactory';
import { RacingStandingFactory } from './RacingStandingFactory'; import { RacingStandingFactory } from './RacingStandingFactory';
import { RacingTeamFactory } from './RacingTeamFactory'; import { RacingTeamFactory, type TeamStats } from './RacingTeamFactory';
import { RacingTrackFactory } from './RacingTrackFactory'; import { RacingTrackFactory } from './RacingTrackFactory';
import { RacingSponsorFactory } from './RacingSponsorFactory'; import { RacingSponsorFactory } from './RacingSponsorFactory';
import { RacingSeasonSponsorshipFactory } from './RacingSeasonSponsorshipFactory'; import { RacingSeasonSponsorshipFactory } from './RacingSeasonSponsorshipFactory';
@@ -36,6 +36,7 @@ export type Friendship = {
export type RacingSeed = { export type RacingSeed = {
drivers: Driver[]; drivers: Driver[];
driverStats: Map<string, DriverStats>;
leagues: League[]; leagues: League[];
seasons: Season[]; seasons: Season[];
seasonSponsorships: SeasonSponsorship[]; seasonSponsorships: SeasonSponsorship[];
@@ -51,6 +52,7 @@ export type RacingSeed = {
leagueJoinRequests: JoinRequest[]; leagueJoinRequests: JoinRequest[];
raceRegistrations: RaceRegistration[]; raceRegistrations: RaceRegistration[];
teams: Team[]; teams: Team[];
teamStats: Map<string, TeamStats>;
teamMemberships: TeamMembership[]; teamMemberships: TeamMembership[];
teamJoinRequests: TeamJoinRequest[]; teamJoinRequests: TeamJoinRequest[];
sponsors: Sponsor[]; sponsors: Sponsor[];
@@ -68,7 +70,7 @@ export type RacingSeedOptions = {
export const racingSeedDefaults: Readonly< export const racingSeedDefaults: Readonly<
Required<RacingSeedOptions> Required<RacingSeedOptions>
> = { > = {
driverCount: 100, driverCount: 150, // Increased from 100 to 150
baseDate: new Date(), baseDate: new Date(),
persistence: 'inmemory', persistence: 'inmemory',
}; };
@@ -98,6 +100,7 @@ class RacingSeedFactory {
const feedFactory = new RacingFeedFactory(this.baseDate, this.persistence); const feedFactory = new RacingFeedFactory(this.baseDate, this.persistence);
const drivers = driverFactory.create(); const drivers = driverFactory.create();
const driverStats = driverFactory.generateDriverStats(drivers);
const tracks = trackFactory.create(); const tracks = trackFactory.create();
const leagueFactory = new RacingLeagueFactory(this.baseDate, drivers, this.persistence); const leagueFactory = new RacingLeagueFactory(this.baseDate, drivers, this.persistence);
const leagues = leagueFactory.create(); const leagues = leagueFactory.create();
@@ -113,6 +116,7 @@ class RacingSeedFactory {
const teamFactory = new RacingTeamFactory(this.baseDate, this.persistence); const teamFactory = new RacingTeamFactory(this.baseDate, this.persistence);
const teams = teamFactory.createTeams(drivers, leagues); const teams = teamFactory.createTeams(drivers, leagues);
const teamStats = teamFactory.generateTeamStats(teams);
const races = raceFactory.create(leagues, tracks); const races = raceFactory.create(leagues, tracks);
const results = resultFactory.create(drivers, races); const results = resultFactory.create(drivers, races);
const standings = standingFactory.create(leagues, races, results); const standings = standingFactory.create(leagues, races, results);
@@ -128,6 +132,7 @@ class RacingSeedFactory {
return { return {
drivers, drivers,
driverStats,
leagues, leagues,
seasons, seasons,
seasonSponsorships, seasonSponsorships,
@@ -143,6 +148,7 @@ class RacingSeedFactory {
leagueJoinRequests, leagueJoinRequests,
raceRegistrations, raceRegistrations,
teams, teams,
teamStats,
teamMemberships, teamMemberships,
teamJoinRequests, teamJoinRequests,
sponsors, sponsors,

View File

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

View File

@@ -5,6 +5,17 @@ import type { TeamJoinRequest, TeamMembership } from '@core/racing/domain/types/
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import { seedId } from './SeedIdHelper'; 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 { export class RacingTeamFactory {
constructor( constructor(
private readonly baseDate: Date, private readonly baseDate: Date,
@@ -12,7 +23,7 @@ export class RacingTeamFactory {
) {} ) {}
createTeams(drivers: Driver[], leagues: League[]): Team[] { createTeams(drivers: Driver[], leagues: League[]): Team[] {
const teamCount = 15; const teamCount = 50; // Increased from 15 to 50
return Array.from({ length: teamCount }, (_, idx) => { return Array.from({ length: teamCount }, (_, idx) => {
const i = idx + 1; const i = idx + 1;
@@ -177,8 +188,98 @@ export class RacingTeamFactory {
return requests; 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<string, TeamStats> {
const statsMap = new Map<string, TeamStats>();
// 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 { private addDays(date: Date, days: number): Date {
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000); return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
} }
} }

View File

@@ -22,7 +22,19 @@ export class InMemoryImageServiceAdapter implements IImageServicePort {
getTeamLogo(teamId: string): string { getTeamLogo(teamId: string): string {
this.logger.debug(`[InMemoryImageServiceAdapter] Getting logo for team: ${teamId}`); 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 { getLeagueCover(leagueId: string): string {
@@ -34,4 +46,4 @@ export class InMemoryImageServiceAdapter implements IImageServicePort {
this.logger.debug(`[InMemoryImageServiceAdapter] Getting logo for league: ${leagueId}`); this.logger.debug(`[InMemoryImageServiceAdapter] Getting logo for league: ${leagueId}`);
return '/images/ff1600.jpeg'; return '/images/ff1600.jpeg';
} }
} }

View File

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

View File

@@ -1,33 +1,17 @@
import type { IDriverStatsService, DriverStats } from '@core/racing/domain/services/IDriverStatsService'; import type { IDriverStatsService, DriverStats } from '@core/racing/domain/services/IDriverStatsService';
import type { Logger } from '@core/shared/application'; import type { Logger } from '@core/shared/application';
import { DriverStatsStore } from './DriverStatsStore';
export class InMemoryDriverStatsService implements IDriverStatsService { export class InMemoryDriverStatsService implements IDriverStatsService {
private store: DriverStatsStore;
constructor(private readonly logger: Logger) { constructor(private readonly logger: Logger) {
this.logger.info('InMemoryDriverStatsService initialized.'); this.logger.info('InMemoryDriverStatsService initialized.');
this.store = DriverStatsStore.getInstance();
} }
getDriverStats(driverId: string): DriverStats | null { getDriverStats(driverId: string): DriverStats | null {
this.logger.debug(`[InMemoryDriverStatsService] Getting stats for driver: ${driverId}`); this.logger.debug(`[InMemoryDriverStatsService] Getting stats for driver: ${driverId}`);
return this.store.getDriverStats(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;
} }
} }

View File

@@ -1,5 +1,6 @@
import type { IRankingService, DriverRanking } from '@core/racing/domain/services/IRankingService'; import type { IRankingService, DriverRanking } from '@core/racing/domain/services/IRankingService';
import type { Logger } from '@core/shared/application'; import type { Logger } from '@core/shared/application';
import { DriverStatsStore } from './DriverStatsStore';
export class InMemoryRankingService implements IRankingService { export class InMemoryRankingService implements IRankingService {
constructor(private readonly logger: Logger) { constructor(private readonly logger: Logger) {
@@ -9,12 +10,29 @@ export class InMemoryRankingService implements IRankingService {
getAllDriverRankings(): DriverRanking[] { getAllDriverRankings(): DriverRanking[] {
this.logger.debug('[InMemoryRankingService] Getting all driver rankings.'); this.logger.debug('[InMemoryRankingService] Getting all driver rankings.');
// Mock data for demonstration purposes // Get stats from the DriverStatsStore
const mockRankings: DriverRanking[] = [ const statsStore = DriverStatsStore.getInstance();
{ driverId: 'driver-1', rating: 2500, overallRank: 1 }, const allStats = statsStore.getAllStats();
{ driverId: 'driver-2', rating: 2400, overallRank: 2 },
{ driverId: 'driver-3', rating: 2300, overallRank: 3 }, // Convert stats to rankings
]; const rankings: DriverRanking[] = [];
return mockRankings;
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;
} }
} }

View File

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

View File

@@ -2,7 +2,7 @@ import type { Logger } from '@core/shared/application';
import type { EnsureInitialData } from '../../../../../adapters/bootstrap/EnsureInitialData'; import type { EnsureInitialData } from '../../../../../adapters/bootstrap/EnsureInitialData';
import { SeedRacingData, type RacingSeedDependencies } from '../../../../../adapters/bootstrap/SeedRacingData'; import { SeedRacingData, type RacingSeedDependencies } from '../../../../../adapters/bootstrap/SeedRacingData';
import { Inject, Module, OnModuleInit } from '@nestjs/common'; 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 { RacingPersistenceModule } from '../../persistence/racing/RacingPersistenceModule';
import { SocialPersistenceModule } from '../../persistence/social/SocialPersistenceModule'; import { SocialPersistenceModule } from '../../persistence/social/SocialPersistenceModule';
import { AchievementPersistenceModule } from '../../persistence/achievement/AchievementPersistenceModule'; import { AchievementPersistenceModule } from '../../persistence/achievement/AchievementPersistenceModule';
@@ -48,7 +48,21 @@ export class BootstrapModule implements OnModuleInit {
if (persistence !== 'postgres') return false; if (persistence !== 'postgres') return false;
if (process.env.NODE_ENV === 'production') 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<boolean> { private async isRacingDatabaseEmpty(): Promise<boolean> {
@@ -58,4 +72,24 @@ export class BootstrapModule implements OnModuleInit {
const leagues = await this.seedDeps.leagueRepository.findAll(); const leagues = await this.seedDeps.leagueRepository.findAll();
return leagues.length === 0; return leagues.length === 0;
} }
private async needsReseed(): Promise<boolean> {
// 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;
}
}
} }

View File

@@ -18,4 +18,19 @@ export class GetDriverOutputDTO {
@ApiProperty() @ApiProperty()
joinedAt!: string; 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;
} }

View File

@@ -1,6 +1,7 @@
import { Result } from '@core/shared/application/Result'; import { Result } from '@core/shared/application/Result';
import type { Driver } from '@core/racing/domain/entities/Driver'; import type { Driver } from '@core/racing/domain/entities/Driver';
import type { GetDriverOutputDTO } from '../dtos/GetDriverOutputDTO'; import type { GetDriverOutputDTO } from '../dtos/GetDriverOutputDTO';
import { DriverStatsStore } from '@adapters/racing/services/DriverStatsStore';
export class DriverPresenter { export class DriverPresenter {
private responseModel: GetDriverOutputDTO | null = null; private responseModel: GetDriverOutputDTO | null = null;
@@ -18,6 +19,10 @@ export class DriverPresenter {
return; return;
} }
// Get stats from the store
const statsStore = DriverStatsStore.getInstance();
const stats = statsStore.getDriverStats(driver.id);
this.responseModel = { this.responseModel = {
id: driver.id, id: driver.id,
iracingId: driver.iracingId.toString(), iracingId: driver.iracingId.toString(),
@@ -25,10 +30,25 @@ export class DriverPresenter {
country: driver.country.toString(), country: driver.country.toString(),
joinedAt: driver.joinedAt.toDate().toISOString(), joinedAt: driver.joinedAt.toDate().toISOString(),
...(driver.bio ? { bio: driver.bio.toString() } : {}), ...(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 { getResponseModel(): GetDriverOutputDTO | null {
return this.responseModel; 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';
}
} }

View File

@@ -27,5 +27,20 @@ export class TeamListItemDTO {
@ApiProperty({ type: [String], required: false }) @ApiProperty({ type: [String], required: false })
languages?: string[]; 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;
} }

View File

@@ -1,6 +1,7 @@
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { GetAllTeamsResult } from '@core/racing/application/use-cases/GetAllTeamsUseCase'; import type { GetAllTeamsResult } from '@core/racing/application/use-cases/GetAllTeamsUseCase';
import { GetAllTeamsOutputDTO } from '../dtos/GetAllTeamsOutputDTO'; import { GetAllTeamsOutputDTO } from '../dtos/GetAllTeamsOutputDTO';
import { TeamStatsStore } from '@adapters/racing/services/TeamStatsStore';
export class AllTeamsPresenter implements UseCaseOutputPort<GetAllTeamsResult> { export class AllTeamsPresenter implements UseCaseOutputPort<GetAllTeamsResult> {
private model: GetAllTeamsOutputDTO | null = null; private model: GetAllTeamsOutputDTO | null = null;
@@ -10,16 +11,41 @@ export class AllTeamsPresenter implements UseCaseOutputPort<GetAllTeamsResult> {
} }
present(result: GetAllTeamsResult): void { present(result: GetAllTeamsResult): void {
const statsStore = TeamStatsStore.getInstance();
this.model = { this.model = {
teams: result.teams.map(team => ({ teams: result.teams.map(team => {
id: team.id, const stats = statsStore.getTeamStats(team.id.toString());
name: team.name.toString(),
tag: team.tag.toString(), return {
description: team.description?.toString() || '', id: team.id,
memberCount: team.memberCount, name: team.name.toString(),
leagues: team.leagues?.map(l => l.toString()) || [], tag: team.tag.toString(),
// Note: specialization, region, languages not available in output 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, totalCount: result.totalCount ?? result.teams.length,
}; };
} }

View File

@@ -57,6 +57,21 @@ export function getEnableBootstrap(): boolean {
return isTruthyEnv(raw); 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. * When set, the API will generate `openapi.json` and optionally reduce logging noise.
* *

115
plans/seeds-plan.md Normal file
View File

@@ -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)."