seed data

This commit is contained in:
2025-12-26 23:06:23 +01:00
parent b4f86abf90
commit f3a89ed87f
15 changed files with 825 additions and 712 deletions

View File

@@ -3,21 +3,6 @@ import { SignupWithEmailUseCase } from '@core/identity/application/use-cases/Sig
import { CreateAchievementUseCase } from '@core/identity/application/use-cases/achievement/CreateAchievementUseCase';
import type { Logger } from '@core/shared/application';
import type { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
import type { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository';
import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository';
import type { IStandingRepository } from '@core/racing/domain/repositories/IStandingRepository';
import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
import type { ITeamRepository } from '@core/racing/domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '@core/racing/domain/repositories/ITeamMembershipRepository';
import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository';
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
import { createInMemoryRacingSeed } from './inmemory/InMemoryRacingSeed';
import {
DRIVER_ACHIEVEMENTS,
STEWARD_ACHIEVEMENTS,
@@ -25,26 +10,12 @@ import {
COMMUNITY_ACHIEVEMENTS,
} from '@core/identity/domain/AchievementConstants';
export type InMemorySeedDependencies = {
driverRepository: IDriverRepository;
leagueRepository: ILeagueRepository;
raceRepository: IRaceRepository;
resultRepository: IResultRepository;
standingRepository: IStandingRepository;
leagueMembershipRepository: ILeagueMembershipRepository;
raceRegistrationRepository: IRaceRegistrationRepository;
teamRepository: ITeamRepository;
teamMembershipRepository: ITeamMembershipRepository;
feedRepository: IFeedRepository;
socialGraphRepository: ISocialGraphRepository;
};
export class EnsureInitialData {
constructor(
private readonly signupUseCase: SignupWithEmailUseCase,
private readonly createAchievementUseCase: CreateAchievementUseCase,
private readonly logger: Logger,
private readonly seedDeps?: InMemorySeedDependencies,
) {}
async execute(): Promise<void> {
@@ -88,125 +59,5 @@ export class EnsureInitialData {
}
this.logger.info(`[Bootstrap] Achievements: ${createdCount} created, ${existingCount} already exist`);
await this.seedInMemoryRacingDataIfNeeded();
}
private shouldSeedInMemory(): boolean {
const configured = (process.env.GRIDPILOT_API_PERSISTENCE ?? '').toLowerCase();
if (configured) {
return configured === 'inmemory';
}
return process.env.DATABASE_URL === undefined;
}
private async seedInMemoryRacingDataIfNeeded(): Promise<void> {
if (!this.shouldSeedInMemory()) {
return;
}
if (!this.seedDeps) {
this.logger.info('[Bootstrap] In-memory racing seed skipped (missing dependencies)');
return;
}
const existingDrivers = await this.seedDeps.driverRepository.findAll();
if (existingDrivers.length > 0) {
this.logger.info('[Bootstrap] In-memory racing seed skipped (drivers already exist)');
return;
}
const seed = createInMemoryRacingSeed();
for (const driver of seed.drivers) {
try {
await this.seedDeps.driverRepository.create(driver);
} catch {
// ignore duplicates
}
}
for (const league of seed.leagues) {
try {
await this.seedDeps.leagueRepository.create(league);
} catch {
// ignore duplicates
}
}
for (const race of seed.races) {
try {
await this.seedDeps.raceRepository.create(race);
} catch {
// ignore duplicates
}
}
try {
await this.seedDeps.resultRepository.createMany(seed.results);
} catch {
// ignore duplicates
}
for (const membership of seed.leagueMemberships) {
try {
await this.seedDeps.leagueMembershipRepository.saveMembership(membership);
} catch {
// ignore duplicates
}
}
for (const team of seed.teams) {
try {
await this.seedDeps.teamRepository.create(team);
} catch {
// ignore duplicates
}
}
for (const membership of seed.teamMemberships) {
try {
await this.seedDeps.teamMembershipRepository.saveMembership(membership);
} catch {
// ignore duplicates
}
}
for (const registration of seed.raceRegistrations) {
try {
await this.seedDeps.raceRegistrationRepository.register(registration);
} catch {
// ignore duplicates
}
}
try {
await this.seedDeps.standingRepository.saveMany(seed.standings);
} catch {
// ignore duplicates
}
const seedableFeed = this.seedDeps.feedRepository as unknown as { seed?: (input: unknown) => void };
if (typeof seedableFeed.seed === 'function') {
seedableFeed.seed({
drivers: seed.drivers,
friendships: seed.friendships,
feedEvents: seed.feedEvents,
});
}
const seedableSocial = this.seedDeps.socialGraphRepository as unknown as { seed?: (input: unknown) => void };
if (typeof seedableSocial.seed === 'function') {
seedableSocial.seed({
drivers: seed.drivers,
friendships: seed.friendships,
feedEvents: seed.feedEvents,
});
}
this.logger.info(
`[Bootstrap] Seeded in-memory racing data: drivers=${seed.drivers.length}, leagues=${seed.leagues.length}, races=${seed.races.length}`,
);
}
}
}

View File

@@ -0,0 +1,134 @@
import type { Logger } from '@core/shared/application';
import type { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
import type { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository';
import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository';
import type { IStandingRepository } from '@core/racing/domain/repositories/IStandingRepository';
import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
import type { ITeamRepository } from '@core/racing/domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '@core/racing/domain/repositories/ITeamMembershipRepository';
import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository';
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
import { createRacingSeed } from './racing/RacingSeed';
export type InMemorySeedDependencies = {
driverRepository: IDriverRepository;
leagueRepository: ILeagueRepository;
raceRepository: IRaceRepository;
resultRepository: IResultRepository;
standingRepository: IStandingRepository;
leagueMembershipRepository: ILeagueMembershipRepository;
raceRegistrationRepository: IRaceRegistrationRepository;
teamRepository: ITeamRepository;
teamMembershipRepository: ITeamMembershipRepository;
feedRepository: IFeedRepository;
socialGraphRepository: ISocialGraphRepository;
};
export class SeedInMemoryRacingData {
constructor(
private readonly logger: Logger,
private readonly seedDeps: InMemorySeedDependencies,
) {}
async execute(): Promise<void> {
const existingDrivers = await this.seedDeps.driverRepository.findAll();
if (existingDrivers.length > 0) {
this.logger.info('[Bootstrap] In-memory racing seed skipped (drivers already exist)');
return;
}
const seed = createRacingSeed();
for (const driver of seed.drivers) {
try {
await this.seedDeps.driverRepository.create(driver);
} catch {
// ignore duplicates
}
}
for (const league of seed.leagues) {
try {
await this.seedDeps.leagueRepository.create(league);
} catch {
// ignore duplicates
}
}
for (const race of seed.races) {
try {
await this.seedDeps.raceRepository.create(race);
} catch {
// ignore duplicates
}
}
try {
await this.seedDeps.resultRepository.createMany(seed.results);
} catch {
// ignore duplicates
}
for (const membership of seed.leagueMemberships) {
try {
await this.seedDeps.leagueMembershipRepository.saveMembership(membership);
} catch {
// ignore duplicates
}
}
for (const team of seed.teams) {
try {
await this.seedDeps.teamRepository.create(team);
} catch {
// ignore duplicates
}
}
for (const membership of seed.teamMemberships) {
try {
await this.seedDeps.teamMembershipRepository.saveMembership(membership);
} catch {
// ignore duplicates
}
}
for (const registration of seed.raceRegistrations) {
try {
await this.seedDeps.raceRegistrationRepository.register(registration);
} catch {
// ignore duplicates
}
}
try {
await this.seedDeps.standingRepository.saveMany(seed.standings);
} catch {
// ignore duplicates
}
const seedableFeed = this.seedDeps.feedRepository as unknown as { seed?: (input: unknown) => void };
if (typeof seedableFeed.seed === 'function') {
seedableFeed.seed({
drivers: seed.drivers,
friendships: seed.friendships,
feedEvents: seed.feedEvents,
});
}
const seedableSocial = this.seedDeps.socialGraphRepository as unknown as { seed?: (input: unknown) => void };
if (typeof seedableSocial.seed === 'function') {
seedableSocial.seed({
drivers: seed.drivers,
friendships: seed.friendships,
feedEvents: seed.feedEvents,
});
}
this.logger.info(
`[Bootstrap] Seeded in-memory racing data: drivers=${seed.drivers.length}, leagues=${seed.leagues.length}, races=${seed.races.length}`,
);
}
}

View File

@@ -1,557 +0,0 @@
import { Driver } from '@core/racing/domain/entities/Driver';
import { League } from '@core/racing/domain/entities/League';
import { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
import { Race } from '@core/racing/domain/entities/Race';
import { RaceRegistration } from '@core/racing/domain/entities/RaceRegistration';
import { Result as RaceResult } from '@core/racing/domain/entities/result/Result';
import { Standing } from '@core/racing/domain/entities/Standing';
import { Team } from '@core/racing/domain/entities/Team';
import type { TeamMembership } from '@core/racing/domain/types/TeamMembership';
import type { FeedItem } from '@core/social/domain/types/FeedItem';
import { getPointsSystems } from '../PointsSystems';
export type Friendship = {
driverId: string;
friendId: string;
};
export type InMemoryRacingSeed = {
drivers: Driver[];
leagues: League[];
races: Race[];
results: RaceResult[];
standings: Standing[];
leagueMemberships: LeagueMembership[];
raceRegistrations: RaceRegistration[];
teams: Team[];
teamMemberships: TeamMembership[];
friendships: Friendship[];
feedEvents: FeedItem[];
};
export type InMemoryRacingSeedOptions = {
driverCount?: number;
baseDate?: Date;
};
export const inMemoryRacingSeedDefaults: Readonly<
Required<InMemoryRacingSeedOptions>
> = {
driverCount: 32,
baseDate: new Date('2025-01-15T12:00:00.000Z'),
};
class InMemoryRacingSeedFactory {
private readonly driverCount: number;
private readonly baseDate: Date;
constructor(options: InMemoryRacingSeedOptions) {
this.driverCount = options.driverCount ?? inMemoryRacingSeedDefaults.driverCount;
this.baseDate = options.baseDate ?? inMemoryRacingSeedDefaults.baseDate;
}
create(): InMemoryRacingSeed {
const drivers = this.createDrivers();
const leagues = this.createLeagues();
const races = this.createRaces(leagues);
const results = this.createResults(drivers, races);
const standings = this.createStandings(leagues, races, results);
const leagueMemberships = this.createLeagueMemberships(drivers, leagues);
const raceRegistrations = this.createRaceRegistrations(races);
const teams = this.createTeams();
const teamMemberships = this.createTeamMemberships(drivers, teams);
const friendships = this.createFriendships(drivers);
const feedEvents = this.createFeedEvents(drivers, friendships, races, leagues);
return {
drivers,
leagues,
races,
results,
standings,
leagueMemberships,
raceRegistrations,
teams,
teamMemberships,
friendships,
feedEvents,
};
}
private addDays(date: Date, days: number): Date {
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
}
private addMinutes(date: Date, minutes: number): Date {
return new Date(date.getTime() + minutes * 60 * 1000);
}
private createDrivers(): Driver[] {
const countries = ['DE', 'NL', 'FR', 'GB', 'US', 'CA', 'SE', 'NO', 'IT', 'ES'] as const;
return Array.from({ length: this.driverCount }, (_, idx) => {
const i = idx + 1;
return Driver.create({
id: `driver-${i}`,
iracingId: String(100000 + i),
name: `Driver ${i}`,
country: countries[idx % countries.length]!,
bio: `Demo driver #${i} seeded for in-memory mode.`,
joinedAt: this.addDays(this.baseDate, -90 + i),
});
});
}
private createLeagues(): League[] {
const createdAtBase = this.baseDate;
return [
League.create({
id: 'league-1',
name: 'GridPilot Sprint Series',
description: 'Weekly sprint races with stable grids.',
ownerId: 'driver-1',
settings: {
pointsSystem: 'f1-2024',
maxDrivers: 24,
sessionDuration: 60,
qualifyingFormat: 'open',
},
createdAt: this.addDays(createdAtBase, -200),
socialLinks: {
discordUrl: 'https://discord.gg/gridpilot-demo',
youtubeUrl: 'https://youtube.com/@gridpilot-demo',
websiteUrl: 'https://gridpilot-demo.example.com',
},
}),
League.create({
id: 'league-2',
name: 'GridPilot Endurance Cup',
description: 'Longer races with strategy and consistency.',
ownerId: 'driver-2',
settings: {
pointsSystem: 'indycar',
maxDrivers: 32,
sessionDuration: 120,
qualifyingFormat: 'open',
},
createdAt: this.addDays(createdAtBase, -180),
socialLinks: { discordUrl: 'https://discord.gg/gridpilot-endurance' },
}),
League.create({
id: 'league-3',
name: 'GridPilot Club Ladder',
description: 'Casual ladder with fast onboarding.',
ownerId: 'driver-3',
settings: {
pointsSystem: 'f1-2024',
maxDrivers: 48,
sessionDuration: 45,
qualifyingFormat: 'single-lap',
},
createdAt: this.addDays(createdAtBase, -160),
}),
League.create({
id: 'league-4',
name: 'Nordic Night Series',
description: 'Evening races with tight fields.',
ownerId: 'driver-4',
settings: {
pointsSystem: 'f1-2024',
maxDrivers: 32,
sessionDuration: 60,
qualifyingFormat: 'open',
},
createdAt: this.addDays(createdAtBase, -150),
}),
League.create({
id: 'league-5',
name: 'Demo League (Admin)',
description: 'Primary demo league owned by driver-1.',
ownerId: 'driver-1',
settings: {
pointsSystem: 'f1-2024',
maxDrivers: 24,
sessionDuration: 60,
qualifyingFormat: 'open',
},
createdAt: this.addDays(createdAtBase, -140),
}),
League.create({
id: 'league-6',
name: 'Sim Racing Alliance',
description: 'Mixed-format season with community events.',
ownerId: 'driver-5',
settings: {
pointsSystem: 'indycar',
maxDrivers: 40,
sessionDuration: 90,
qualifyingFormat: 'open',
},
createdAt: this.addDays(createdAtBase, -130),
}),
];
}
private createRaces(leagues: League[]): Race[] {
const tracks = [
'Monza GP',
'Spa-Francorchamps',
'Suzuka',
'Mount Panorama',
'Silverstone GP',
'Interlagos',
'Imola',
'Laguna Seca',
];
const cars = ['GT3 Porsche 911', 'GT3 BMW M4', 'LMP3 Prototype', 'GT4 Alpine', 'Touring Civic'];
const leagueIds = leagues.map((l) => l.id.toString());
const demoLeagueId = 'league-5';
const races: Race[] = [];
for (let i = 1; i <= 25; i++) {
const leagueId = leagueIds[(i - 1) % leagueIds.length] ?? demoLeagueId;
const scheduledAt = this.addDays(this.baseDate, i <= 10 ? -35 + i : 1 + (i - 10) * 2);
const base = {
id: `race-${i}`,
leagueId,
scheduledAt,
track: tracks[(i - 1) % tracks.length]!,
car: cars[(i - 1) % cars.length]!,
};
if (i === 1) {
races.push(
Race.create({
...base,
leagueId: demoLeagueId,
scheduledAt: this.addMinutes(this.baseDate, -30),
status: 'running',
strengthOfField: 1530,
registeredCount: 16,
}),
);
continue;
}
if (scheduledAt < this.baseDate) {
races.push(
Race.create({
...base,
status: 'completed',
}),
);
continue;
}
races.push(
Race.create({
...base,
status: 'scheduled',
}),
);
}
return races;
}
private createResults(drivers: Driver[], races: Race[]): RaceResult[] {
const results: RaceResult[] = [];
const completed = races.filter((r) => r.status === 'completed');
for (const race of completed) {
const participants = drivers.slice(0, Math.min(16, drivers.length));
for (let idx = 0; idx < participants.length; idx++) {
const driver = participants[idx]!;
const position = idx + 1;
const startPosition = ((idx + 3) % participants.length) + 1;
results.push(
RaceResult.create({
id: `${race.id}:${driver.id}`,
raceId: race.id,
driverId: driver.id,
position,
startPosition,
fastestLap: 88_000 + idx * 120,
incidents: idx % 4 === 0 ? 2 : 0,
}),
);
}
}
return results;
}
private resolvePointsSystem(
league: League,
pointsSystems: Record<string, Record<number, number>>,
): Record<number, number> {
const settings = league.settings;
return settings.customPoints ?? pointsSystems[settings.pointsSystem] ?? pointsSystems['f1-2024'] ?? {};
}
private createStandings(leagues: League[], races: Race[], results: RaceResult[]): Standing[] {
const pointsSystems = getPointsSystems();
const racesByLeague = new Map<string, Set<string>>();
for (const race of races) {
if (race.status !== 'completed') continue;
const set = racesByLeague.get(race.leagueId) ?? new Set<string>();
set.add(race.id);
racesByLeague.set(race.leagueId, set);
}
const standings: Standing[] = [];
for (const league of leagues) {
const leagueId = league.id.toString();
const completedRaceIds = racesByLeague.get(leagueId) ?? new Set<string>();
if (completedRaceIds.size === 0) continue;
const pointsTable = this.resolvePointsSystem(league, pointsSystems);
const byDriver = new Map<string, Standing>();
for (const result of results) {
if (!completedRaceIds.has(result.raceId.toString())) continue;
const driverId = result.driverId.toString();
const previousStanding = byDriver.get(driverId) ?? Standing.create({ leagueId, driverId, position: 1 });
const nextStanding = previousStanding.addRaceResult(result.position.toNumber(), pointsTable);
byDriver.set(driverId, nextStanding);
}
const sorted = Array.from(byDriver.values()).sort((a, b) => {
if (b.points.toNumber() !== a.points.toNumber()) return b.points.toNumber() - a.points.toNumber();
if (b.wins !== a.wins) return b.wins - a.wins;
return b.racesCompleted - a.racesCompleted;
});
sorted.forEach((standing, index) => standings.push(standing.updatePosition(index + 1)));
}
return standings;
}
private createLeagueMemberships(drivers: Driver[], leagues: League[]): LeagueMembership[] {
const memberships: LeagueMembership[] = [];
for (const driver of drivers) {
const driverId = driver.id;
memberships.push(
LeagueMembership.create({
leagueId: 'league-5',
driverId,
role: driverId === 'driver-1' ? 'owner' : 'member',
status: 'active',
joinedAt: this.addDays(this.baseDate, -60),
}),
);
const driverNumber = Number(driverId.split('-')[1]);
const extraLeague = leagues[(driverNumber % (leagues.length - 1)) + 1];
if (extraLeague) {
memberships.push(
LeagueMembership.create({
leagueId: extraLeague.id.toString(),
driverId,
role: 'member',
status: 'active',
joinedAt: this.addDays(this.baseDate, -40),
}),
);
}
}
return memberships;
}
private createRaceRegistrations(races: Race[]): RaceRegistration[] {
const registrations: RaceRegistration[] = [];
const upcomingDemoLeague = races.filter((r) => r.status === 'scheduled' && r.leagueId === 'league-5').slice(0, 3);
for (const race of upcomingDemoLeague) {
registrations.push(
RaceRegistration.create({
raceId: race.id,
driverId: 'driver-1',
}),
);
}
return registrations;
}
private createTeams(): Team[] {
return [
Team.create({
id: 'team-1',
name: 'Apex Racing',
tag: 'APEX',
description: 'Demo team focused on clean racing.',
ownerId: 'driver-1',
leagues: ['league-5'],
createdAt: this.addDays(this.baseDate, -100),
}),
Team.create({
id: 'team-2',
name: 'Night Owls',
tag: 'NITE',
description: 'Late-night grinders and endurance lovers.',
ownerId: 'driver-2',
leagues: ['league-4'],
createdAt: this.addDays(this.baseDate, -90),
}),
Team.create({
id: 'team-3',
name: 'Club Legends',
tag: 'CLUB',
description: 'A casual team for ladder climbing.',
ownerId: 'driver-3',
leagues: ['league-3'],
createdAt: this.addDays(this.baseDate, -80),
}),
];
}
private createTeamMemberships(drivers: Driver[], teams: Team[]): TeamMembership[] {
const memberships: TeamMembership[] = [];
const team1 = teams.find((t) => t.id === 'team-1');
const team2 = teams.find((t) => t.id === 'team-2');
const team3 = teams.find((t) => t.id === 'team-3');
if (team1) {
const members = drivers.slice(0, 6);
members.forEach((d, idx) => {
memberships.push({
teamId: team1.id,
driverId: d.id,
role: d.id === team1.ownerId.toString() ? 'owner' : idx === 1 ? 'manager' : 'driver',
status: 'active',
joinedAt: this.addDays(this.baseDate, -50),
});
});
}
if (team2) {
const members = drivers.slice(6, 12);
members.forEach((d) => {
memberships.push({
teamId: team2.id,
driverId: d.id,
role: d.id === team2.ownerId.toString() ? 'owner' : 'driver',
status: 'active',
joinedAt: this.addDays(this.baseDate, -45),
});
});
}
if (team3) {
const members = drivers.slice(12, 18);
members.forEach((d) => {
memberships.push({
teamId: team3.id,
driverId: d.id,
role: d.id === team3.ownerId.toString() ? 'owner' : 'driver',
status: 'active',
joinedAt: this.addDays(this.baseDate, -40),
});
});
}
return memberships;
}
private createFriendships(drivers: Driver[]): Friendship[] {
const friendships: Friendship[] = [];
for (let i = 0; i < drivers.length; i++) {
const driver = drivers[i]!;
for (let offset = 1; offset <= 3; offset++) {
const friend = drivers[(i + offset) % drivers.length]!;
friendships.push({ driverId: driver.id, friendId: friend.id });
}
}
return friendships;
}
private createFeedEvents(drivers: Driver[], friendships: Friendship[], races: Race[], leagues: League[]): FeedItem[] {
const items: FeedItem[] = [];
const friendMap = new Map(friendships.map((f) => [`${f.driverId}:${f.friendId}`, true] as const));
const completedRace = races.find((r) => r.status === 'completed');
const upcomingRace = races.find((r) => r.status === 'scheduled');
const league = leagues.find((l) => l.id.toString() === 'league-5') ?? leagues[0]!;
const now = this.addMinutes(this.baseDate, 10);
for (let i = 2; i <= 10; i++) {
const actor = drivers.find((d) => d.id === `driver-${i}`);
if (!actor) continue;
if (!friendMap.has(`driver-1:${actor.id}`)) continue;
items.push({
id: `feed:${actor.id}:joined:${i}`,
type: 'friend-joined-league',
timestamp: this.addMinutes(now, -(i * 7)),
actorDriverId: actor.id,
actorFriendId: actor.id,
leagueId: league.id.toString(),
headline: `${actor.name} joined ${String(league.name)}`,
body: 'Demo activity in in-memory mode.',
ctaLabel: 'View league',
ctaHref: `/leagues/${league.id.toString()}`,
});
if (completedRace) {
items.push({
id: `feed:${actor.id}:result:${i}`,
type: 'friend-finished-race',
timestamp: this.addMinutes(now, -(i * 7 + 3)),
actorDriverId: actor.id,
actorFriendId: actor.id,
leagueId: completedRace.leagueId,
raceId: completedRace.id,
position: (i % 5) + 1,
headline: `${actor.name} finished a race`,
body: `Completed at ${completedRace.track}.`,
ctaLabel: 'View results',
ctaHref: `/races/${completedRace.id}/results`,
});
}
}
if (upcomingRace) {
items.push({
id: `feed:system:scheduled:${upcomingRace.id}`,
type: 'new-race-scheduled',
timestamp: this.addMinutes(now, -3),
leagueId: upcomingRace.leagueId,
raceId: upcomingRace.id,
headline: `New race scheduled at ${upcomingRace.track}`,
body: `${upcomingRace.car}${upcomingRace.scheduledAt.toISOString()}`,
ctaLabel: 'View schedule',
ctaHref: `/races/${upcomingRace.id}`,
});
}
return items.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
}
}
export function createInMemoryRacingSeed(options: InMemoryRacingSeedOptions = {}): InMemoryRacingSeed {
return new InMemoryRacingSeedFactory(options).create();
}

View File

@@ -0,0 +1,29 @@
import { Driver } from '@core/racing/domain/entities/Driver';
export class RacingDriverFactory {
constructor(
private readonly driverCount: number,
private readonly baseDate: Date,
) {}
create(): Driver[] {
const countries = ['DE', 'NL', 'FR', 'GB', 'US', 'CA', 'SE', 'NO', 'IT', 'ES'] as const;
return Array.from({ length: this.driverCount }, (_, idx) => {
const i = idx + 1;
return Driver.create({
id: `driver-${i}`,
iracingId: String(100000 + i),
name: `Driver ${i}`,
country: countries[idx % countries.length]!,
bio: `Demo driver #${i} seeded for in-memory mode.`,
joinedAt: this.addDays(this.baseDate, -90 + i),
});
});
}
private addDays(date: Date, days: number): Date {
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
}
}

View File

@@ -0,0 +1,77 @@
import { Driver } from '@core/racing/domain/entities/Driver';
import { League } from '@core/racing/domain/entities/League';
import { Race } from '@core/racing/domain/entities/Race';
import type { FeedItem } from '@core/social/domain/types/FeedItem';
import type { Friendship } from './RacingSeed';
export class RacingFeedFactory {
constructor(private readonly baseDate: Date) {}
create(drivers: Driver[], friendships: Friendship[], races: Race[], leagues: League[]): FeedItem[] {
const items: FeedItem[] = [];
const friendMap = new Map(friendships.map((f) => [`${f.driverId}:${f.friendId}`, true] as const));
const completedRace = races.find((r) => r.status === 'completed');
const upcomingRace = races.find((r) => r.status === 'scheduled');
const league = leagues.find((l) => l.id.toString() === 'league-5') ?? leagues[0]!;
const now = this.addMinutes(this.baseDate, 10);
for (let i = 2; i <= 10; i++) {
const actor = drivers.find((d) => d.id === `driver-${i}`);
if (!actor) continue;
if (!friendMap.has(`driver-1:${actor.id}`)) continue;
items.push({
id: `feed:${actor.id}:joined:${i}`,
type: 'friend-joined-league',
timestamp: this.addMinutes(now, -(i * 7)),
actorDriverId: actor.id,
actorFriendId: actor.id,
leagueId: league.id.toString(),
headline: `${actor.name} joined ${String(league.name)}`,
body: 'Demo activity in in-memory mode.',
ctaLabel: 'View league',
ctaHref: `/leagues/${league.id.toString()}`,
});
if (completedRace) {
items.push({
id: `feed:${actor.id}:result:${i}`,
type: 'friend-finished-race',
timestamp: this.addMinutes(now, -(i * 7 + 3)),
actorDriverId: actor.id,
actorFriendId: actor.id,
leagueId: completedRace.leagueId,
raceId: completedRace.id,
position: (i % 5) + 1,
headline: `${actor.name} finished a race`,
body: `Completed at ${completedRace.track}.`,
ctaLabel: 'View results',
ctaHref: `/races/${completedRace.id}/results`,
});
}
}
if (upcomingRace) {
items.push({
id: `feed:system:scheduled:${upcomingRace.id}`,
type: 'new-race-scheduled',
timestamp: this.addMinutes(now, -3),
leagueId: upcomingRace.leagueId,
raceId: upcomingRace.id,
headline: `New race scheduled at ${upcomingRace.track}`,
body: `${upcomingRace.car}${upcomingRace.scheduledAt.toISOString()}`,
ctaLabel: 'View schedule',
ctaHref: `/races/${upcomingRace.id}`,
});
}
return items.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
}
private addMinutes(date: Date, minutes: number): Date {
return new Date(date.getTime() + minutes * 60 * 1000);
}
}

View File

@@ -0,0 +1,18 @@
import { Driver } from '@core/racing/domain/entities/Driver';
import type { Friendship } from './RacingSeed';
export class RacingFriendshipFactory {
create(drivers: Driver[]): Friendship[] {
const friendships: Friendship[] = [];
for (let i = 0; i < drivers.length; i++) {
const driver = drivers[i]!;
for (let offset = 1; offset <= 3; offset++) {
const friend = drivers[(i + offset) % drivers.length]!;
friendships.push({ driverId: driver.id, friendId: friend.id });
}
}
return friendships;
}
}

View File

@@ -0,0 +1,100 @@
import { League } from '@core/racing/domain/entities/League';
export class RacingLeagueFactory {
constructor(private readonly baseDate: Date) {}
create(): League[] {
const createdAtBase = this.baseDate;
return [
League.create({
id: 'league-1',
name: 'GridPilot Sprint Series',
description: 'Weekly sprint races with stable grids.',
ownerId: 'driver-1',
settings: {
pointsSystem: 'f1-2024',
maxDrivers: 24,
sessionDuration: 60,
qualifyingFormat: 'open',
},
createdAt: this.addDays(createdAtBase, -200),
socialLinks: {
discordUrl: 'https://discord.gg/gridpilot-demo',
youtubeUrl: 'https://youtube.com/@gridpilot-demo',
websiteUrl: 'https://gridpilot-demo.example.com',
},
}),
League.create({
id: 'league-2',
name: 'GridPilot Endurance Cup',
description: 'Longer races with strategy and consistency.',
ownerId: 'driver-2',
settings: {
pointsSystem: 'indycar',
maxDrivers: 32,
sessionDuration: 120,
qualifyingFormat: 'open',
},
createdAt: this.addDays(createdAtBase, -180),
socialLinks: { discordUrl: 'https://discord.gg/gridpilot-endurance' },
}),
League.create({
id: 'league-3',
name: 'GridPilot Club Ladder',
description: 'Casual ladder with fast onboarding.',
ownerId: 'driver-3',
settings: {
pointsSystem: 'f1-2024',
maxDrivers: 48,
sessionDuration: 45,
qualifyingFormat: 'single-lap',
},
createdAt: this.addDays(createdAtBase, -160),
}),
League.create({
id: 'league-4',
name: 'Nordic Night Series',
description: 'Evening races with tight fields.',
ownerId: 'driver-4',
settings: {
pointsSystem: 'f1-2024',
maxDrivers: 32,
sessionDuration: 60,
qualifyingFormat: 'open',
},
createdAt: this.addDays(createdAtBase, -150),
}),
League.create({
id: 'league-5',
name: 'Demo League (Admin)',
description: 'Primary demo league owned by driver-1.',
ownerId: 'driver-1',
settings: {
pointsSystem: 'f1-2024',
maxDrivers: 24,
sessionDuration: 60,
qualifyingFormat: 'open',
},
createdAt: this.addDays(createdAtBase, -140),
}),
League.create({
id: 'league-6',
name: 'Sim Racing Alliance',
description: 'Mixed-format season with community events.',
ownerId: 'driver-5',
settings: {
pointsSystem: 'indycar',
maxDrivers: 40,
sessionDuration: 90,
qualifyingFormat: 'open',
},
createdAt: this.addDays(createdAtBase, -130),
}),
];
}
private addDays(date: Date, days: number): Date {
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
}
}

View File

@@ -0,0 +1,65 @@
import { Driver } from '@core/racing/domain/entities/Driver';
import { League } from '@core/racing/domain/entities/League';
import { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
import { Race } from '@core/racing/domain/entities/Race';
import { RaceRegistration } from '@core/racing/domain/entities/RaceRegistration';
export class RacingMembershipFactory {
constructor(private readonly baseDate: Date) {}
createLeagueMemberships(drivers: Driver[], leagues: League[]): LeagueMembership[] {
const memberships: LeagueMembership[] = [];
for (const driver of drivers) {
const driverId = driver.id;
memberships.push(
LeagueMembership.create({
leagueId: 'league-5',
driverId,
role: driverId === 'driver-1' ? 'owner' : 'member',
status: 'active',
joinedAt: this.addDays(this.baseDate, -60),
}),
);
const driverNumber = Number(driverId.split('-')[1]);
const extraLeague = leagues[(driverNumber % (leagues.length - 1)) + 1];
if (extraLeague) {
memberships.push(
LeagueMembership.create({
leagueId: extraLeague.id.toString(),
driverId,
role: 'member',
status: 'active',
joinedAt: this.addDays(this.baseDate, -40),
}),
);
}
}
return memberships;
}
createRaceRegistrations(races: Race[]): RaceRegistration[] {
const registrations: RaceRegistration[] = [];
const upcomingDemoLeague = races.filter((r) => r.status === 'scheduled' && r.leagueId === 'league-5').slice(0, 3);
for (const race of upcomingDemoLeague) {
registrations.push(
RaceRegistration.create({
raceId: race.id,
driverId: 'driver-1',
}),
);
}
return registrations;
}
private addDays(date: Date, days: number): Date {
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
}
}

View File

@@ -0,0 +1,79 @@
import { League } from '@core/racing/domain/entities/League';
import { Race } from '@core/racing/domain/entities/Race';
export class RacingRaceFactory {
constructor(private readonly baseDate: Date) {}
create(leagues: League[]): Race[] {
const tracks = [
'Monza GP',
'Spa-Francorchamps',
'Suzuka',
'Mount Panorama',
'Silverstone GP',
'Interlagos',
'Imola',
'Laguna Seca',
];
const cars = ['GT3 Porsche 911', 'GT3 BMW M4', 'LMP3 Prototype', 'GT4 Alpine', 'Touring Civic'];
const leagueIds = leagues.map((l) => l.id.toString());
const demoLeagueId = 'league-5';
const races: Race[] = [];
for (let i = 1; i <= 25; i++) {
const leagueId = leagueIds[(i - 1) % leagueIds.length] ?? demoLeagueId;
const scheduledAt = this.addDays(this.baseDate, i <= 10 ? -35 + i : 1 + (i - 10) * 2);
const base = {
id: `race-${i}`,
leagueId,
scheduledAt,
track: tracks[(i - 1) % tracks.length]!,
car: cars[(i - 1) % cars.length]!,
};
if (i === 1) {
races.push(
Race.create({
...base,
leagueId: demoLeagueId,
scheduledAt: this.addMinutes(this.baseDate, -30),
status: 'running',
strengthOfField: 1530,
registeredCount: 16,
}),
);
continue;
}
if (scheduledAt < this.baseDate) {
races.push(
Race.create({
...base,
status: 'completed',
}),
);
continue;
}
races.push(
Race.create({
...base,
status: 'scheduled',
}),
);
}
return races;
}
private addDays(date: Date, days: number): Date {
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
}
private addMinutes(date: Date, minutes: number): Date {
return new Date(date.getTime() + minutes * 60 * 1000);
}
}

View File

@@ -0,0 +1,34 @@
import { Driver } from '@core/racing/domain/entities/Driver';
import { Race } from '@core/racing/domain/entities/Race';
import { Result as RaceResult } from '@core/racing/domain/entities/result/Result';
export class RacingResultFactory {
create(drivers: Driver[], races: Race[]): RaceResult[] {
const results: RaceResult[] = [];
const completed = races.filter((r) => r.status === 'completed');
for (const race of completed) {
const participants = drivers.slice(0, Math.min(16, drivers.length));
for (let idx = 0; idx < participants.length; idx++) {
const driver = participants[idx]!;
const position = idx + 1;
const startPosition = ((idx + 3) % participants.length) + 1;
results.push(
RaceResult.create({
id: `${race.id}:${driver.id}`,
raceId: race.id,
driverId: driver.id,
position,
startPosition,
fastestLap: 88_000 + idx * 120,
incidents: idx % 4 === 0 ? 2 : 0,
}),
);
}
}
return results;
}
}

View File

@@ -0,0 +1,102 @@
import { Driver } from '@core/racing/domain/entities/Driver';
import { League } from '@core/racing/domain/entities/League';
import { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
import { Race } from '@core/racing/domain/entities/Race';
import { RaceRegistration } from '@core/racing/domain/entities/RaceRegistration';
import { Result as RaceResult } from '@core/racing/domain/entities/result/Result';
import { Standing } from '@core/racing/domain/entities/Standing';
import { Team } from '@core/racing/domain/entities/Team';
import type { TeamMembership } from '@core/racing/domain/types/TeamMembership';
import type { FeedItem } from '@core/social/domain/types/FeedItem';
import { RacingDriverFactory } from './RacingDriverFactory';
import { RacingFeedFactory } from './RacingFeedFactory';
import { RacingFriendshipFactory } from './RacingFriendshipFactory';
import { RacingLeagueFactory } from './RacingLeagueFactory';
import { RacingMembershipFactory } from './RacingMembershipFactory';
import { RacingRaceFactory } from './RacingRaceFactory';
import { RacingResultFactory } from './RacingResultFactory';
import { RacingStandingFactory } from './RacingStandingFactory';
import { RacingTeamFactory } from './RacingTeamFactory';
export type Friendship = {
driverId: string;
friendId: string;
};
export type RacingSeed = {
drivers: Driver[];
leagues: League[];
races: Race[];
results: RaceResult[];
standings: Standing[];
leagueMemberships: LeagueMembership[];
raceRegistrations: RaceRegistration[];
teams: Team[];
teamMemberships: TeamMembership[];
friendships: Friendship[];
feedEvents: FeedItem[];
};
export type RacingSeedOptions = {
driverCount?: number;
baseDate?: Date;
};
export const racingSeedDefaults: Readonly<
Required<RacingSeedOptions>
> = {
driverCount: 32,
baseDate: new Date('2025-01-15T12:00:00.000Z'),
};
class RacingSeedFactory {
private readonly driverCount: number;
private readonly baseDate: Date;
constructor(options: RacingSeedOptions) {
this.driverCount = options.driverCount ?? racingSeedDefaults.driverCount;
this.baseDate = options.baseDate ?? racingSeedDefaults.baseDate;
}
create(): RacingSeed {
const driverFactory = new RacingDriverFactory(this.driverCount, this.baseDate);
const leagueFactory = new RacingLeagueFactory(this.baseDate);
const raceFactory = new RacingRaceFactory(this.baseDate);
const resultFactory = new RacingResultFactory();
const standingFactory = new RacingStandingFactory();
const membershipFactory = new RacingMembershipFactory(this.baseDate);
const teamFactory = new RacingTeamFactory(this.baseDate);
const friendshipFactory = new RacingFriendshipFactory();
const feedFactory = new RacingFeedFactory(this.baseDate);
const drivers = driverFactory.create();
const leagues = leagueFactory.create();
const races = raceFactory.create(leagues);
const results = resultFactory.create(drivers, races);
const standings = standingFactory.create(leagues, races, results);
const leagueMemberships = membershipFactory.createLeagueMemberships(drivers, leagues);
const raceRegistrations = membershipFactory.createRaceRegistrations(races);
const teams = teamFactory.createTeams();
const teamMemberships = teamFactory.createTeamMemberships(drivers, teams);
const friendships = friendshipFactory.create(drivers);
const feedEvents = feedFactory.create(drivers, friendships, races, leagues);
return {
drivers,
leagues,
races,
results,
standings,
leagueMemberships,
raceRegistrations,
teams,
teamMemberships,
friendships,
feedEvents,
};
}
}
export function createRacingSeed(options: RacingSeedOptions = {}): RacingSeed {
return new RacingSeedFactory(options).create();
}

View File

@@ -0,0 +1,59 @@
import { League } from '@core/racing/domain/entities/League';
import { Race } from '@core/racing/domain/entities/Race';
import { Result as RaceResult } from '@core/racing/domain/entities/result/Result';
import { Standing } from '@core/racing/domain/entities/Standing';
import { getPointsSystems } from '../PointsSystems';
export class RacingStandingFactory {
create(leagues: League[], races: Race[], results: RaceResult[]): Standing[] {
const pointsSystems = getPointsSystems();
const racesByLeague = new Map<string, Set<string>>();
for (const race of races) {
if (race.status !== 'completed') continue;
const set = racesByLeague.get(race.leagueId) ?? new Set<string>();
set.add(race.id);
racesByLeague.set(race.leagueId, set);
}
const standings: Standing[] = [];
for (const league of leagues) {
const leagueId = league.id.toString();
const completedRaceIds = racesByLeague.get(leagueId) ?? new Set<string>();
if (completedRaceIds.size === 0) continue;
const pointsTable = this.resolvePointsSystem(league, pointsSystems);
const byDriver = new Map<string, Standing>();
for (const result of results) {
if (!completedRaceIds.has(result.raceId.toString())) continue;
const driverId = result.driverId.toString();
const previousStanding = byDriver.get(driverId) ?? Standing.create({ leagueId, driverId, position: 1 });
const nextStanding = previousStanding.addRaceResult(result.position.toNumber(), pointsTable);
byDriver.set(driverId, nextStanding);
}
const sorted = Array.from(byDriver.values()).sort((a, b) => {
if (b.points.toNumber() !== a.points.toNumber()) return b.points.toNumber() - a.points.toNumber();
if (b.wins !== a.wins) return b.wins - a.wins;
return b.racesCompleted - a.racesCompleted;
});
sorted.forEach((standing, index) => standings.push(standing.updatePosition(index + 1)));
}
return standings;
}
private resolvePointsSystem(
league: League,
pointsSystems: Record<string, Record<number, number>>,
): Record<number, number> {
const settings = league.settings;
return settings.customPoints ?? pointsSystems[settings.pointsSystem] ?? pointsSystems['f1-2024'] ?? {};
}
}

View File

@@ -0,0 +1,92 @@
import { Driver } from '@core/racing/domain/entities/Driver';
import { Team } from '@core/racing/domain/entities/Team';
import type { TeamMembership } from '@core/racing/domain/types/TeamMembership';
export class RacingTeamFactory {
constructor(private readonly baseDate: Date) {}
createTeams(): Team[] {
return [
Team.create({
id: 'team-1',
name: 'Apex Racing',
tag: 'APEX',
description: 'Demo team focused on clean racing.',
ownerId: 'driver-1',
leagues: ['league-5'],
createdAt: this.addDays(this.baseDate, -100),
}),
Team.create({
id: 'team-2',
name: 'Night Owls',
tag: 'NITE',
description: 'Late-night grinders and endurance lovers.',
ownerId: 'driver-2',
leagues: ['league-4'],
createdAt: this.addDays(this.baseDate, -90),
}),
Team.create({
id: 'team-3',
name: 'Club Legends',
tag: 'CLUB',
description: 'A casual team for ladder climbing.',
ownerId: 'driver-3',
leagues: ['league-3'],
createdAt: this.addDays(this.baseDate, -80),
}),
];
}
createTeamMemberships(drivers: Driver[], teams: Team[]): TeamMembership[] {
const memberships: TeamMembership[] = [];
const team1 = teams.find((t) => t.id === 'team-1');
const team2 = teams.find((t) => t.id === 'team-2');
const team3 = teams.find((t) => t.id === 'team-3');
if (team1) {
const members = drivers.slice(0, 6);
members.forEach((d, idx) => {
memberships.push({
teamId: team1.id,
driverId: d.id,
role: d.id === team1.ownerId.toString() ? 'owner' : idx === 1 ? 'manager' : 'driver',
status: 'active',
joinedAt: this.addDays(this.baseDate, -50),
});
});
}
if (team2) {
const members = drivers.slice(6, 12);
members.forEach((d) => {
memberships.push({
teamId: team2.id,
driverId: d.id,
role: d.id === team2.ownerId.toString() ? 'owner' : 'driver',
status: 'active',
joinedAt: this.addDays(this.baseDate, -45),
});
});
}
if (team3) {
const members = drivers.slice(12, 18);
members.forEach((d) => {
memberships.push({
teamId: team3.id,
driverId: d.id,
role: d.id === team3.ownerId.toString() ? 'owner' : 'driver',
status: 'active',
joinedAt: this.addDays(this.baseDate, -40),
});
});
}
return memberships;
}
private addDays(date: Date, days: number): Date {
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
}
}

View File

@@ -1,4 +1,5 @@
import { EnsureInitialData } from '../../../../../adapters/bootstrap/EnsureInitialData';
import { SeedInMemoryRacingData } from '../../../../../adapters/bootstrap/SeedInMemoryRacingData';
import { Module, OnModuleInit } from '@nestjs/common';
import { BootstrapProviders } from './BootstrapProviders';
@@ -6,16 +7,33 @@ import { BootstrapProviders } from './BootstrapProviders';
providers: BootstrapProviders,
})
export class BootstrapModule implements OnModuleInit {
constructor(private readonly ensureInitialData: EnsureInitialData) {}
constructor(
private readonly ensureInitialData: EnsureInitialData,
private readonly seedInMemoryRacingData: SeedInMemoryRacingData,
) {}
async onModuleInit() {
console.log('[Bootstrap] Initializing application data...');
try {
await this.ensureInitialData.execute();
if (this.shouldSeedInMemory()) {
await this.seedInMemoryRacingData.execute();
}
console.log('[Bootstrap] Application data initialized successfully');
} catch (error) {
console.error('[Bootstrap] Failed to initialize application data:', error);
throw error;
}
}
private shouldSeedInMemory(): boolean {
const configured = (process.env.GRIDPILOT_API_PERSISTENCE ?? '').toLowerCase();
if (configured) {
return configured === 'inmemory';
}
return process.env.DATABASE_URL === undefined;
}
}

View File

@@ -1,5 +1,6 @@
import { Provider } from '@nestjs/common';
import { EnsureInitialData, type InMemorySeedDependencies } from '../../../../../adapters/bootstrap/EnsureInitialData';
import { EnsureInitialData } from '../../../../../adapters/bootstrap/EnsureInitialData';
import { SeedInMemoryRacingData, type InMemorySeedDependencies } from '../../../../../adapters/bootstrap/SeedInMemoryRacingData';
import { SignupWithEmailUseCase, type SignupWithEmailResult } from '@core/identity/application/use-cases/SignupWithEmailUseCase';
import {
CreateAchievementUseCase,
@@ -87,6 +88,19 @@ export const BootstrapProviders: Provider[] = [
signupUseCase: SignupWithEmailUseCase,
createAchievementUseCase: CreateAchievementUseCase,
logger: Logger,
) => {
return new EnsureInitialData(signupUseCase, createAchievementUseCase, logger);
},
inject: [
SIGNUP_USE_CASE_TOKEN,
CREATE_ACHIEVEMENT_USE_CASE_TOKEN,
'Logger',
],
},
{
provide: SeedInMemoryRacingData,
useFactory: (
logger: Logger,
driverRepository: InMemorySeedDependencies['driverRepository'],
leagueRepository: InMemorySeedDependencies['leagueRepository'],
raceRepository: InMemorySeedDependencies['raceRepository'],
@@ -113,11 +127,9 @@ export const BootstrapProviders: Provider[] = [
socialGraphRepository,
};
return new EnsureInitialData(signupUseCase, createAchievementUseCase, logger, deps);
return new SeedInMemoryRacingData(logger, deps);
},
inject: [
SIGNUP_USE_CASE_TOKEN,
CREATE_ACHIEVEMENT_USE_CASE_TOKEN,
'Logger',
'IDriverRepository',
'ILeagueRepository',