seed data
This commit is contained in:
@@ -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`)
|
||||||
|
|||||||
@@ -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)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)!;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
50
adapters/racing/services/DriverStatsStore.ts
Normal file
50
adapters/racing/services/DriverStatsStore.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
adapters/racing/services/TeamStatsStore.ts
Normal file
50
adapters/racing/services/TeamStatsStore.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
115
plans/seeds-plan.md
Normal 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)."
|
||||||
Reference in New Issue
Block a user