From f3a89ed87f0c1e4a045ba9dd1a986afde3e41f38 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Fri, 26 Dec 2025 23:06:23 +0100 Subject: [PATCH] seed data --- adapters/bootstrap/EnsureInitialData.ts | 151 +---- adapters/bootstrap/SeedInMemoryRacingData.ts | 134 +++++ .../bootstrap/inmemory/InMemoryRacingSeed.ts | 557 ------------------ .../bootstrap/racing/RacingDriverFactory.ts | 29 + .../bootstrap/racing/RacingFeedFactory.ts | 77 +++ .../racing/RacingFriendshipFactory.ts | 18 + .../bootstrap/racing/RacingLeagueFactory.ts | 100 ++++ .../racing/RacingMembershipFactory.ts | 65 ++ .../bootstrap/racing/RacingRaceFactory.ts | 79 +++ .../bootstrap/racing/RacingResultFactory.ts | 34 ++ adapters/bootstrap/racing/RacingSeed.ts | 102 ++++ .../bootstrap/racing/RacingStandingFactory.ts | 59 ++ .../bootstrap/racing/RacingTeamFactory.ts | 92 +++ .../src/domain/bootstrap/BootstrapModule.ts | 20 +- .../domain/bootstrap/BootstrapProviders.ts | 20 +- 15 files changed, 825 insertions(+), 712 deletions(-) create mode 100644 adapters/bootstrap/SeedInMemoryRacingData.ts delete mode 100644 adapters/bootstrap/inmemory/InMemoryRacingSeed.ts create mode 100644 adapters/bootstrap/racing/RacingDriverFactory.ts create mode 100644 adapters/bootstrap/racing/RacingFeedFactory.ts create mode 100644 adapters/bootstrap/racing/RacingFriendshipFactory.ts create mode 100644 adapters/bootstrap/racing/RacingLeagueFactory.ts create mode 100644 adapters/bootstrap/racing/RacingMembershipFactory.ts create mode 100644 adapters/bootstrap/racing/RacingRaceFactory.ts create mode 100644 adapters/bootstrap/racing/RacingResultFactory.ts create mode 100644 adapters/bootstrap/racing/RacingSeed.ts create mode 100644 adapters/bootstrap/racing/RacingStandingFactory.ts create mode 100644 adapters/bootstrap/racing/RacingTeamFactory.ts diff --git a/adapters/bootstrap/EnsureInitialData.ts b/adapters/bootstrap/EnsureInitialData.ts index 01f34cbbb..cb59931c5 100644 --- a/adapters/bootstrap/EnsureInitialData.ts +++ b/adapters/bootstrap/EnsureInitialData.ts @@ -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 { @@ -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 { - 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}`, - ); - } -} \ No newline at end of file +} diff --git a/adapters/bootstrap/SeedInMemoryRacingData.ts b/adapters/bootstrap/SeedInMemoryRacingData.ts new file mode 100644 index 000000000..ea3aaa9c0 --- /dev/null +++ b/adapters/bootstrap/SeedInMemoryRacingData.ts @@ -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 { + 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}`, + ); + } +} \ No newline at end of file diff --git a/adapters/bootstrap/inmemory/InMemoryRacingSeed.ts b/adapters/bootstrap/inmemory/InMemoryRacingSeed.ts deleted file mode 100644 index 020e6c3d5..000000000 --- a/adapters/bootstrap/inmemory/InMemoryRacingSeed.ts +++ /dev/null @@ -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 -> = { - 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>, - ): Record { - 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>(); - for (const race of races) { - if (race.status !== 'completed') continue; - - const set = racesByLeague.get(race.leagueId) ?? new Set(); - 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(); - if (completedRaceIds.size === 0) continue; - - const pointsTable = this.resolvePointsSystem(league, pointsSystems); - - const byDriver = new Map(); - - 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(); -} \ No newline at end of file diff --git a/adapters/bootstrap/racing/RacingDriverFactory.ts b/adapters/bootstrap/racing/RacingDriverFactory.ts new file mode 100644 index 000000000..5ea5ebaf5 --- /dev/null +++ b/adapters/bootstrap/racing/RacingDriverFactory.ts @@ -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); + } +} \ No newline at end of file diff --git a/adapters/bootstrap/racing/RacingFeedFactory.ts b/adapters/bootstrap/racing/RacingFeedFactory.ts new file mode 100644 index 000000000..ec2dd73b3 --- /dev/null +++ b/adapters/bootstrap/racing/RacingFeedFactory.ts @@ -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); + } +} \ No newline at end of file diff --git a/adapters/bootstrap/racing/RacingFriendshipFactory.ts b/adapters/bootstrap/racing/RacingFriendshipFactory.ts new file mode 100644 index 000000000..24d7bbb5c --- /dev/null +++ b/adapters/bootstrap/racing/RacingFriendshipFactory.ts @@ -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; + } +} \ No newline at end of file diff --git a/adapters/bootstrap/racing/RacingLeagueFactory.ts b/adapters/bootstrap/racing/RacingLeagueFactory.ts new file mode 100644 index 000000000..4c8f5b403 --- /dev/null +++ b/adapters/bootstrap/racing/RacingLeagueFactory.ts @@ -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); + } +} \ No newline at end of file diff --git a/adapters/bootstrap/racing/RacingMembershipFactory.ts b/adapters/bootstrap/racing/RacingMembershipFactory.ts new file mode 100644 index 000000000..284ae8539 --- /dev/null +++ b/adapters/bootstrap/racing/RacingMembershipFactory.ts @@ -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); + } +} \ No newline at end of file diff --git a/adapters/bootstrap/racing/RacingRaceFactory.ts b/adapters/bootstrap/racing/RacingRaceFactory.ts new file mode 100644 index 000000000..43aa3a4d5 --- /dev/null +++ b/adapters/bootstrap/racing/RacingRaceFactory.ts @@ -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); + } +} \ No newline at end of file diff --git a/adapters/bootstrap/racing/RacingResultFactory.ts b/adapters/bootstrap/racing/RacingResultFactory.ts new file mode 100644 index 000000000..ea93b4483 --- /dev/null +++ b/adapters/bootstrap/racing/RacingResultFactory.ts @@ -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; + } +} \ No newline at end of file diff --git a/adapters/bootstrap/racing/RacingSeed.ts b/adapters/bootstrap/racing/RacingSeed.ts new file mode 100644 index 000000000..4b53bfae6 --- /dev/null +++ b/adapters/bootstrap/racing/RacingSeed.ts @@ -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 +> = { + 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(); +} \ No newline at end of file diff --git a/adapters/bootstrap/racing/RacingStandingFactory.ts b/adapters/bootstrap/racing/RacingStandingFactory.ts new file mode 100644 index 000000000..99440d78b --- /dev/null +++ b/adapters/bootstrap/racing/RacingStandingFactory.ts @@ -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>(); + for (const race of races) { + if (race.status !== 'completed') continue; + + const set = racesByLeague.get(race.leagueId) ?? new Set(); + 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(); + if (completedRaceIds.size === 0) continue; + + const pointsTable = this.resolvePointsSystem(league, pointsSystems); + + const byDriver = new Map(); + + 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>, + ): Record { + const settings = league.settings; + return settings.customPoints ?? pointsSystems[settings.pointsSystem] ?? pointsSystems['f1-2024'] ?? {}; + } +} \ No newline at end of file diff --git a/adapters/bootstrap/racing/RacingTeamFactory.ts b/adapters/bootstrap/racing/RacingTeamFactory.ts new file mode 100644 index 000000000..da0d8de87 --- /dev/null +++ b/adapters/bootstrap/racing/RacingTeamFactory.ts @@ -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); + } +} \ No newline at end of file diff --git a/apps/api/src/domain/bootstrap/BootstrapModule.ts b/apps/api/src/domain/bootstrap/BootstrapModule.ts index 54bf60676..9470ee711 100644 --- a/apps/api/src/domain/bootstrap/BootstrapModule.ts +++ b/apps/api/src/domain/bootstrap/BootstrapModule.ts @@ -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; + } } \ No newline at end of file diff --git a/apps/api/src/domain/bootstrap/BootstrapProviders.ts b/apps/api/src/domain/bootstrap/BootstrapProviders.ts index b750bd230..d8a2969bb 100644 --- a/apps/api/src/domain/bootstrap/BootstrapProviders.ts +++ b/apps/api/src/domain/bootstrap/BootstrapProviders.ts @@ -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',