seed data
This commit is contained in:
@@ -78,6 +78,10 @@ export class SeedRacingData {
|
||||
return process.env.DATABASE_URL ? 'postgres' : 'inmemory';
|
||||
}
|
||||
|
||||
private getMediaBaseUrl(): string {
|
||||
return process.env.NODE_ENV === 'development' ? 'http://localhost:3001' : 'https://api.gridpilot.io';
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
const existingDrivers = await this.seedDeps.driverRepository.findAll();
|
||||
const persistence = this.getApiPersistence();
|
||||
@@ -428,7 +432,7 @@ export class SeedRacingData {
|
||||
private calculateTeamStats(team: Team, results: Result[], drivers: Driver[]): TeamStats {
|
||||
const wins = results.filter(r => r.position.toNumber() === 1).length;
|
||||
const totalRaces = results.length;
|
||||
|
||||
|
||||
// Calculate rating
|
||||
const baseRating = 1000;
|
||||
const winBonus = wins * 50;
|
||||
@@ -462,7 +466,7 @@ export class SeedRacingData {
|
||||
})));
|
||||
|
||||
return {
|
||||
logoUrl: `https://api.gridpilot.io/media/team/${team.id}/logo.png`,
|
||||
logoUrl: `${this.getMediaBaseUrl()}/api/media/teams/${team.id}/logo`,
|
||||
performanceLevel,
|
||||
specialization,
|
||||
region,
|
||||
@@ -482,21 +486,22 @@ export class SeedRacingData {
|
||||
}
|
||||
|
||||
private async seedMediaAssets(seed: any): Promise<void> {
|
||||
// Seed driver avatars
|
||||
const baseUrl = this.getMediaBaseUrl();
|
||||
|
||||
// Seed driver avatars using static files
|
||||
for (const driver of seed.drivers) {
|
||||
const avatarUrl = `https://api.gridpilot.io/media/driver/${driver.id}/avatar.png`;
|
||||
|
||||
// Type assertion to access the helper method
|
||||
const avatarUrl = this.getDriverAvatarUrl(driver.id);
|
||||
|
||||
const mediaRepo = this.seedDeps.mediaRepository as any;
|
||||
if (mediaRepo.setDriverAvatar) {
|
||||
mediaRepo.setDriverAvatar(driver.id, avatarUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Seed team logos
|
||||
// Seed team logos using API routes
|
||||
for (const team of seed.teams) {
|
||||
const logoUrl = `https://api.gridpilot.io/media/team/${team.id}/logo.png`;
|
||||
|
||||
const logoUrl = `${baseUrl}/api/media/teams/${team.id}/logo`;
|
||||
|
||||
const mediaRepo = this.seedDeps.mediaRepository as any;
|
||||
if (mediaRepo.setTeamLogo) {
|
||||
mediaRepo.setTeamLogo(team.id, logoUrl);
|
||||
@@ -505,8 +510,8 @@ export class SeedRacingData {
|
||||
|
||||
// Seed track images
|
||||
for (const track of seed.tracks || []) {
|
||||
const trackImageUrl = `https://api.gridpilot.io/media/track/${track.id}/image.png`;
|
||||
|
||||
const trackImageUrl = `${baseUrl}/api/media/tracks/${track.id}/image`;
|
||||
|
||||
const mediaRepo = this.seedDeps.mediaRepository as any;
|
||||
if (mediaRepo.setTrackImage) {
|
||||
mediaRepo.setTrackImage(track.id, trackImageUrl);
|
||||
@@ -516,8 +521,8 @@ export class SeedRacingData {
|
||||
// Seed category icons (if categories exist)
|
||||
const categories = ['beginner', 'intermediate', 'advanced', 'pro', 'endurance', 'sprint'];
|
||||
for (const category of categories) {
|
||||
const iconUrl = `https://api.gridpilot.io/media/category/${category}/icon.png`;
|
||||
|
||||
const iconUrl = `${baseUrl}/api/media/categories/${category}/icon`;
|
||||
|
||||
const mediaRepo = this.seedDeps.mediaRepository as any;
|
||||
if (mediaRepo.setCategoryIcon) {
|
||||
mediaRepo.setCategoryIcon(category, iconUrl);
|
||||
@@ -526,8 +531,8 @@ export class SeedRacingData {
|
||||
|
||||
// Seed sponsor logos
|
||||
for (const sponsor of seed.sponsors || []) {
|
||||
const logoUrl = `https://api.gridpilot.io/media/sponsor/${sponsor.id}/logo.png`;
|
||||
|
||||
const logoUrl = `${baseUrl}/api/media/sponsors/${sponsor.id}/logo`;
|
||||
|
||||
const mediaRepo = this.seedDeps.mediaRepository as any;
|
||||
if (mediaRepo.setSponsorLogo) {
|
||||
mediaRepo.setSponsorLogo(sponsor.id, logoUrl);
|
||||
@@ -537,6 +542,48 @@ export class SeedRacingData {
|
||||
this.logger.info(`[Bootstrap] Seeded media assets for ${seed.drivers.length} drivers, ${seed.teams.length} teams`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get deterministic avatar URL for a driver based on their ID
|
||||
* Uses static files from the website public directory
|
||||
*/
|
||||
private getDriverAvatarUrl(driverId: string): string {
|
||||
// Deterministic selection based on driver ID
|
||||
const numericSuffixMatch = driverId.match(/(\d+)$/);
|
||||
let useFemale = false;
|
||||
let useNeutral = false;
|
||||
|
||||
if (numericSuffixMatch && numericSuffixMatch[1]) {
|
||||
const numericSuffix = parseInt(numericSuffixMatch[1], 10);
|
||||
// 40% female, 40% male, 20% neutral
|
||||
if (numericSuffix % 5 === 0) {
|
||||
useNeutral = true;
|
||||
} else if (numericSuffix % 2 === 0) {
|
||||
useFemale = true;
|
||||
}
|
||||
} else {
|
||||
// Fallback hash
|
||||
let hash = 0;
|
||||
for (let i = 0; i < driverId.length; i++) {
|
||||
hash = (hash * 31 + driverId.charCodeAt(i)) | 0;
|
||||
}
|
||||
const hashValue = Math.abs(hash);
|
||||
if (hashValue % 5 === 0) {
|
||||
useNeutral = true;
|
||||
} else if (hashValue % 2 === 0) {
|
||||
useFemale = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Return static file paths that Next.js can serve
|
||||
if (useNeutral) {
|
||||
return '/images/avatars/neutral-default-avatar.jpeg';
|
||||
} else if (useFemale) {
|
||||
return '/images/avatars/female-default-avatar.jpeg';
|
||||
} else {
|
||||
return '/images/avatars/male-default-avatar.jpg';
|
||||
}
|
||||
}
|
||||
|
||||
private async clearExistingRacingData(): Promise<void> {
|
||||
// Get all existing drivers
|
||||
const drivers = await this.seedDeps.driverRepository.findAll();
|
||||
|
||||
@@ -25,8 +25,51 @@ export class RacingDriverFactory {
|
||||
private readonly persistence: 'postgres' | 'inmemory' = 'inmemory',
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get deterministic avatar URL for a driver based on their ID
|
||||
* Uses static files from the website public directory
|
||||
*/
|
||||
getDriverAvatarUrl(driverId: string): string {
|
||||
// Deterministic selection based on driver ID
|
||||
const numericSuffixMatch = driverId.match(/(\d+)$/);
|
||||
let useFemale = false;
|
||||
let useNeutral = false;
|
||||
|
||||
if (numericSuffixMatch && numericSuffixMatch[1]) {
|
||||
const numericSuffix = parseInt(numericSuffixMatch[1], 10);
|
||||
// 40% female, 40% male, 20% neutral
|
||||
if (numericSuffix % 5 === 0) {
|
||||
useNeutral = true;
|
||||
} else if (numericSuffix % 2 === 0) {
|
||||
useFemale = true;
|
||||
}
|
||||
} else {
|
||||
// Fallback hash
|
||||
let hash = 0;
|
||||
for (let i = 0; i < driverId.length; i++) {
|
||||
hash = (hash * 31 + driverId.charCodeAt(i)) | 0;
|
||||
}
|
||||
const hashValue = Math.abs(hash);
|
||||
if (hashValue % 5 === 0) {
|
||||
useNeutral = true;
|
||||
} else if (hashValue % 2 === 0) {
|
||||
useFemale = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Return static file paths that Next.js can serve
|
||||
if (useNeutral) {
|
||||
return '/images/avatars/neutral-default-avatar.jpeg';
|
||||
} else if (useFemale) {
|
||||
return '/images/avatars/female-default-avatar.jpeg';
|
||||
} else {
|
||||
return '/images/avatars/male-default-avatar.jpg';
|
||||
}
|
||||
}
|
||||
|
||||
create(): Driver[] {
|
||||
const countries = ['DE', 'NL', 'FR', 'GB', 'US', 'CA', 'SE', 'NO', 'IT', 'ES', 'AU', 'BR', 'JP', 'KR', 'RU', 'PL', 'CZ', 'HU', 'AT', 'CH'] as const;
|
||||
const categories = ['beginner', 'intermediate', 'advanced', 'pro', 'endurance', 'sprint'];
|
||||
|
||||
return Array.from({ length: this.driverCount }, (_, idx) => {
|
||||
const i = idx + 1;
|
||||
@@ -53,6 +96,9 @@ export class RacingDriverFactory {
|
||||
joinedAt = faker.date.past({ years: 2, refDate: this.baseDate });
|
||||
}
|
||||
|
||||
// Assign category - use all available categories
|
||||
const category = faker.helpers.arrayElement(categories);
|
||||
|
||||
const driverData: {
|
||||
id: string;
|
||||
iracingId: string;
|
||||
@@ -60,12 +106,14 @@ export class RacingDriverFactory {
|
||||
country: string;
|
||||
bio?: string;
|
||||
joinedAt?: Date;
|
||||
category?: string;
|
||||
} = {
|
||||
id: seedId(`driver-${i}`, this.persistence),
|
||||
iracingId: String(100000 + i),
|
||||
name: faker.person.fullName(),
|
||||
country: faker.helpers.arrayElement(countries),
|
||||
joinedAt,
|
||||
category,
|
||||
};
|
||||
|
||||
if (hasBio) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { League } from '@core/racing/domain/entities/League';
|
||||
import { League, LeagueSettings } from '@core/racing/domain/entities/League';
|
||||
import { Driver } from '@core/racing/domain/entities/Driver';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { seedId } from './SeedIdHelper';
|
||||
@@ -11,7 +11,7 @@ export class RacingLeagueFactory {
|
||||
) {}
|
||||
|
||||
create(): League[] {
|
||||
const leagueCount = 30;
|
||||
const leagueCount = 120; // Expanded to 100+ leagues
|
||||
|
||||
// Create diverse league configurations covering ALL enum combinations
|
||||
// Points systems: f1-2024, indycar, custom (3)
|
||||
@@ -20,6 +20,8 @@ export class RacingLeagueFactory {
|
||||
// Decision modes: admin_only, steward_decides, steward_vote, member_vote, steward_veto, member_veto (6)
|
||||
// Total combinations: 3 * 2 * 2 * 6 = 72, but we'll sample 30 covering extremes
|
||||
|
||||
// Category types for leagues
|
||||
|
||||
const leagueConfigs = [
|
||||
// 1-5: Ranked, F1-2024, various stewarding
|
||||
{
|
||||
@@ -277,7 +279,8 @@ export class RacingLeagueFactory {
|
||||
return Array.from({ length: leagueCount }, (_, idx) => {
|
||||
const i = idx + 1;
|
||||
const owner = faker.helpers.arrayElement(this.drivers);
|
||||
const config = leagueConfigs[idx]!;
|
||||
// Cycle through the 30 configs for variety
|
||||
const config = leagueConfigs[idx % 30]!;
|
||||
|
||||
const createdAt =
|
||||
// Ensure some "New" leagues (created within 7 days) so `/leagues` featured categories are populated.
|
||||
@@ -310,33 +313,29 @@ export class RacingLeagueFactory {
|
||||
}
|
||||
}
|
||||
|
||||
const leagueData: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
settings: {
|
||||
pointsSystem: 'f1-2024' | 'indycar' | 'custom';
|
||||
maxDrivers: number;
|
||||
sessionDuration: number;
|
||||
qualifyingFormat: 'open' | 'single-lap';
|
||||
visibility?: 'ranked' | 'unranked';
|
||||
stewarding?: {
|
||||
decisionMode: 'admin_only' | 'steward_decides' | 'steward_vote' | 'member_vote' | 'steward_veto' | 'member_veto';
|
||||
requiredVotes?: number;
|
||||
requireDefense?: boolean;
|
||||
defenseTimeLimit?: number;
|
||||
voteTimeLimit?: number;
|
||||
protestDeadlineHours?: number;
|
||||
stewardingClosesHours?: number;
|
||||
notifyAccusedOnProtest?: boolean;
|
||||
notifyOnVoteRequired?: boolean;
|
||||
};
|
||||
};
|
||||
createdAt: Date;
|
||||
socialLinks?: { discordUrl?: string; youtubeUrl?: string; websiteUrl?: string };
|
||||
participantCount?: number;
|
||||
} = {
|
||||
// Determine category based on scoring configuration
|
||||
let category: string | undefined;
|
||||
if (config.pointsSystem === 'f1-2024') {
|
||||
if (config.qualifyingFormat === 'single-lap') {
|
||||
category = 'driver';
|
||||
} else {
|
||||
category = 'team';
|
||||
}
|
||||
} else if (config.pointsSystem === 'indycar') {
|
||||
category = 'nations';
|
||||
} else if (config.pointsSystem === 'custom') {
|
||||
category = 'trophy';
|
||||
}
|
||||
|
||||
// Override some leagues to have endurance or sprint categories
|
||||
if (idx % 8 === 0) {
|
||||
category = 'endurance';
|
||||
} else if (idx % 7 === 0) {
|
||||
category = 'sprint';
|
||||
}
|
||||
|
||||
// Build the league data object
|
||||
const leagueData = {
|
||||
id: seedId(`league-${i}`, this.persistence),
|
||||
name: faker.company.name() + ' Racing League',
|
||||
description: faker.lorem.sentences(2),
|
||||
@@ -356,6 +355,7 @@ export class RacingLeagueFactory {
|
||||
notifyOnVoteRequired: true,
|
||||
},
|
||||
},
|
||||
category,
|
||||
createdAt,
|
||||
participantCount,
|
||||
};
|
||||
@@ -374,11 +374,37 @@ export class RacingLeagueFactory {
|
||||
if (type === 'website') socialLinks.websiteUrl = faker.internet.url();
|
||||
});
|
||||
|
||||
// Create the final league data with optional fields
|
||||
const finalLeagueData: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
settings?: Partial<LeagueSettings>;
|
||||
category?: string | undefined;
|
||||
createdAt?: Date;
|
||||
socialLinks?: {
|
||||
discordUrl?: string;
|
||||
youtubeUrl?: string;
|
||||
websiteUrl?: string;
|
||||
};
|
||||
participantCount?: number;
|
||||
} = {
|
||||
id: leagueData.id,
|
||||
name: leagueData.name,
|
||||
description: leagueData.description,
|
||||
ownerId: leagueData.ownerId,
|
||||
settings: leagueData.settings,
|
||||
category: leagueData.category,
|
||||
createdAt: leagueData.createdAt,
|
||||
participantCount: leagueData.participantCount,
|
||||
};
|
||||
|
||||
if (Object.keys(socialLinks).length > 0) {
|
||||
leagueData.socialLinks = socialLinks;
|
||||
finalLeagueData.socialLinks = socialLinks;
|
||||
}
|
||||
|
||||
return League.create(leagueData);
|
||||
return League.create(finalLeagueData);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export interface TeamStats {
|
||||
totalWins: number;
|
||||
totalRaces: number;
|
||||
rating: number;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export class RacingTeamFactory {
|
||||
@@ -33,6 +34,9 @@ export class RacingTeamFactory {
|
||||
{ min: 0, max: 3 },
|
||||
);
|
||||
|
||||
// 30-50% of teams are recruiting
|
||||
const isRecruiting = faker.datatype.boolean({ probability: 0.4 });
|
||||
|
||||
return Team.create({
|
||||
id: seedId(`team-${i}`, this.persistence),
|
||||
name: faker.company.name() + ' Racing',
|
||||
@@ -40,6 +44,7 @@ export class RacingTeamFactory {
|
||||
description: faker.lorem.sentences(2),
|
||||
ownerId: owner.id,
|
||||
leagues: teamLeagues,
|
||||
isRecruiting,
|
||||
createdAt: faker.date.past({ years: 2, refDate: this.baseDate }),
|
||||
});
|
||||
});
|
||||
@@ -256,6 +261,10 @@ export class RacingTeamFactory {
|
||||
specialization = 'mixed';
|
||||
}
|
||||
|
||||
// Determine category - use all available categories
|
||||
const categories = ['beginner', 'intermediate', 'advanced', 'pro', 'endurance', 'sprint'];
|
||||
const category = faker.helpers.arrayElement(categories);
|
||||
|
||||
// Generate region and languages
|
||||
const region = faker.helpers.arrayElement(regions);
|
||||
const languageCount = faker.number.int({ min: 1, max: 3 });
|
||||
@@ -273,6 +282,7 @@ export class RacingTeamFactory {
|
||||
totalWins,
|
||||
totalRaces,
|
||||
rating,
|
||||
category,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,42 +8,21 @@ export class InMemoryImageServiceAdapter implements IImageServicePort {
|
||||
|
||||
getDriverAvatar(driverId: string): string {
|
||||
this.logger.debug(`[InMemoryImageServiceAdapter] Getting avatar for driver: ${driverId}`);
|
||||
const driverNumber = Number(driverId.replace('driver-', ''));
|
||||
const index = Number.isFinite(driverNumber) ? driverNumber % 3 : 0;
|
||||
|
||||
const avatars = [
|
||||
'/images/avatars/male-default-avatar.jpg',
|
||||
'/images/avatars/female-default-avatar.jpeg',
|
||||
'/images/avatars/neutral-default-avatar.jpeg',
|
||||
] as const;
|
||||
|
||||
return avatars[index] ?? avatars[0];
|
||||
return `/media/avatar/${driverId}`;
|
||||
}
|
||||
|
||||
getTeamLogo(teamId: string): string {
|
||||
this.logger.debug(`[InMemoryImageServiceAdapter] Getting logo for team: ${teamId}`);
|
||||
const teamNumber = Number(teamId.replace('team-', ''));
|
||||
const index = Number.isFinite(teamNumber) ? teamNumber % 6 : 0;
|
||||
|
||||
const logos = [
|
||||
'/images/ff1600.jpeg',
|
||||
'/images/header.jpeg',
|
||||
'/images/avatars/male-default-avatar.jpg',
|
||||
'/images/avatars/female-default-avatar.jpeg',
|
||||
'/images/avatars/neutral-default-avatar.jpeg',
|
||||
'/images/leagues/placeholder-cover.svg',
|
||||
] as const;
|
||||
|
||||
return logos[index] ?? logos[0];
|
||||
return `/media/teams/${teamId}/logo`;
|
||||
}
|
||||
|
||||
getLeagueCover(leagueId: string): string {
|
||||
this.logger.debug(`[InMemoryImageServiceAdapter] Getting cover for league: ${leagueId}`);
|
||||
return '/images/header.jpeg';
|
||||
return `/media/leagues/${leagueId}/cover`;
|
||||
}
|
||||
|
||||
getLeagueLogo(leagueId: string): string {
|
||||
this.logger.debug(`[InMemoryImageServiceAdapter] Getting logo for league: ${leagueId}`);
|
||||
return '/images/ff1600.jpeg';
|
||||
return `/media/leagues/${leagueId}/logo`;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,18 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryTeamStatsRepository
|
||||
*
|
||||
* In-memory implementation of ITeamStatsRepository.
|
||||
* Stores computed team statistics for caching and frontend queries.
|
||||
*/
|
||||
|
||||
import type { Logger } from '@core/shared/application/Logger';
|
||||
import type { ITeamStatsRepository, TeamStats } from '@core/racing/domain/repositories/ITeamStatsRepository';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
|
||||
export class InMemoryTeamStatsRepository implements ITeamStatsRepository {
|
||||
private stats = new Map<string, TeamStats>();
|
||||
private readonly stats = new Map<string, TeamStats>();
|
||||
|
||||
constructor(private readonly logger: Logger) {
|
||||
this.logger.info('[InMemoryTeamStatsRepository] Initialized.');
|
||||
}
|
||||
constructor(private readonly logger: Logger) {}
|
||||
|
||||
async getTeamStats(teamId: string): Promise<TeamStats | null> {
|
||||
this.logger.debug(`[InMemoryTeamStatsRepository] Getting stats for team: ${teamId}`);
|
||||
return this.stats.get(teamId) ?? null;
|
||||
}
|
||||
|
||||
getTeamStatsSync(teamId: string): TeamStats | null {
|
||||
this.logger.debug(`[InMemoryTeamStatsRepository] Getting stats (sync) for team: ${teamId}`);
|
||||
return this.stats.get(teamId) ?? null;
|
||||
return this.stats.get(teamId) || null;
|
||||
}
|
||||
|
||||
async saveTeamStats(teamId: string, stats: TeamStats): Promise<void> {
|
||||
this.logger.debug(`[InMemoryTeamStatsRepository] Saving stats for team: ${teamId}`);
|
||||
this.logger.debug(`[InMemoryTeamStatsRepository] Saving stats for team: ${teamId}`, stats);
|
||||
this.stats.set(teamId, stats);
|
||||
}
|
||||
|
||||
@@ -36,7 +22,7 @@ export class InMemoryTeamStatsRepository implements ITeamStatsRepository {
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.logger.info('[InMemoryTeamStatsRepository] Clearing all stats');
|
||||
this.logger.debug('[InMemoryTeamStatsRepository] Clearing all stats');
|
||||
this.stats.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,4 +19,7 @@ export class DriverOrmEntity {
|
||||
|
||||
@Column({ type: 'timestamptz' })
|
||||
joinedAt!: Date;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
category!: string | null;
|
||||
}
|
||||
@@ -19,6 +19,9 @@ export class LeagueOrmEntity {
|
||||
@Column({ type: 'jsonb' })
|
||||
settings!: SerializedLeagueSettings;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
category!: string | null;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
|
||||
@@ -20,6 +20,12 @@ export class TeamOrmEntity {
|
||||
@Column({ type: 'uuid', array: true })
|
||||
leagues!: string[];
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
category!: string | null;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isRecruiting!: boolean;
|
||||
|
||||
@Column({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
@Entity({ name: 'team_stats' })
|
||||
export class TeamStatsOrmEntity {
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
teamId!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
logoUrl!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
performanceLevel!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
specialization!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
region!: string;
|
||||
|
||||
@Column({ type: 'text', array: true })
|
||||
languages!: string[];
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
totalWins!: number;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
totalRaces!: number;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
rating!: number;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
category!: string | null;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export class DriverOrmMapper {
|
||||
entity.country = domain.country.toString();
|
||||
entity.bio = domain.bio?.toString() ?? null;
|
||||
entity.joinedAt = domain.joinedAt.toDate();
|
||||
entity.category = domain.category ?? null;
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -24,14 +25,31 @@ export class DriverOrmMapper {
|
||||
assertNonEmptyString(entityName, 'country', entity.country);
|
||||
assertDate(entityName, 'joinedAt', entity.joinedAt);
|
||||
assertOptionalStringOrNull(entityName, 'bio', entity.bio);
|
||||
assertOptionalStringOrNull(entityName, 'category', entity.category);
|
||||
|
||||
return Driver.rehydrate({
|
||||
const props: {
|
||||
id: string;
|
||||
iracingId: string;
|
||||
name: string;
|
||||
country: string;
|
||||
bio?: string;
|
||||
joinedAt: Date;
|
||||
category?: string;
|
||||
} = {
|
||||
id: entity.id,
|
||||
iracingId: entity.iracingId,
|
||||
name: entity.name,
|
||||
country: entity.country,
|
||||
...(entity.bio !== null && entity.bio !== undefined ? { bio: entity.bio } : {}),
|
||||
joinedAt: entity.joinedAt,
|
||||
});
|
||||
};
|
||||
|
||||
if (entity.bio !== null && entity.bio !== undefined) {
|
||||
props.bio = entity.bio;
|
||||
}
|
||||
if (entity.category !== null && entity.category !== undefined) {
|
||||
props.category = entity.category;
|
||||
}
|
||||
|
||||
return Driver.rehydrate(props);
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,7 @@ export class LeagueOrmMapper {
|
||||
entity.description = domain.description.toString();
|
||||
entity.ownerId = domain.ownerId.toString();
|
||||
entity.settings = serializeLeagueSettings(domain.settings);
|
||||
entity.category = domain.category ?? null;
|
||||
entity.createdAt = domain.createdAt.toDate();
|
||||
entity.participantCount = domain.getParticipantCount();
|
||||
entity.discordUrl = domain.socialLinks?.discordUrl ?? null;
|
||||
@@ -172,6 +173,7 @@ export class LeagueOrmMapper {
|
||||
description: entity.description,
|
||||
ownerId: entity.ownerId,
|
||||
settings,
|
||||
category: entity.category ?? undefined,
|
||||
createdAt: entity.createdAt,
|
||||
participantCount: entity.participantCount,
|
||||
...(entity.discordUrl || entity.youtubeUrl || entity.websiteUrl
|
||||
|
||||
@@ -25,6 +25,8 @@ export class TeamOrmMapper {
|
||||
entity.description = domain.description.toString();
|
||||
entity.ownerId = domain.ownerId.toString();
|
||||
entity.leagues = domain.leagues.map((l) => l.toString());
|
||||
entity.category = domain.category ?? null;
|
||||
entity.isRecruiting = domain.isRecruiting;
|
||||
entity.createdAt = domain.createdAt.toDate();
|
||||
return entity;
|
||||
}
|
||||
@@ -45,15 +47,32 @@ export class TeamOrmMapper {
|
||||
}
|
||||
|
||||
try {
|
||||
return Team.rehydrate({
|
||||
const rehydrateProps: {
|
||||
id: string;
|
||||
name: string;
|
||||
tag: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
leagues: string[];
|
||||
category?: string;
|
||||
isRecruiting: boolean;
|
||||
createdAt: Date;
|
||||
} = {
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
tag: entity.tag,
|
||||
description: entity.description,
|
||||
ownerId: entity.ownerId,
|
||||
leagues: entity.leagues,
|
||||
isRecruiting: entity.isRecruiting ?? false,
|
||||
createdAt: entity.createdAt,
|
||||
});
|
||||
};
|
||||
|
||||
if (entity.category !== null && entity.category !== undefined) {
|
||||
rehydrateProps.category = entity.category;
|
||||
}
|
||||
|
||||
return Team.rehydrate(rehydrateProps);
|
||||
} catch {
|
||||
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: '__root', reason: 'invalid_shape' });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { TeamStats } from '@core/racing/domain/repositories/ITeamStatsRepository';
|
||||
|
||||
import { TeamStatsOrmEntity } from '../entities/TeamStatsOrmEntity';
|
||||
import {
|
||||
assertNonEmptyString,
|
||||
assertInteger,
|
||||
assertArray,
|
||||
assertEnumValue
|
||||
} from '../schema/TypeOrmSchemaGuards';
|
||||
|
||||
const PERFORMANCE_LEVELS = ['beginner', 'intermediate', 'advanced', 'pro'] as const;
|
||||
const SPECIALIZATIONS = ['endurance', 'sprint', 'mixed'] as const;
|
||||
|
||||
export class TeamStatsOrmMapper {
|
||||
toOrmEntity(teamId: string, domain: TeamStats): TeamStatsOrmEntity {
|
||||
const entity = new TeamStatsOrmEntity();
|
||||
entity.teamId = teamId;
|
||||
entity.logoUrl = domain.logoUrl;
|
||||
entity.performanceLevel = domain.performanceLevel;
|
||||
entity.specialization = domain.specialization;
|
||||
entity.region = domain.region;
|
||||
entity.languages = domain.languages;
|
||||
entity.totalWins = domain.totalWins;
|
||||
entity.totalRaces = domain.totalRaces;
|
||||
entity.rating = domain.rating;
|
||||
entity.category = domain.category ?? null;
|
||||
return entity;
|
||||
}
|
||||
|
||||
toDomain(entity: TeamStatsOrmEntity): TeamStats {
|
||||
const entityName = 'TeamStats';
|
||||
|
||||
assertNonEmptyString(entityName, 'teamId', entity.teamId);
|
||||
assertNonEmptyString(entityName, 'logoUrl', entity.logoUrl);
|
||||
assertEnumValue(entityName, 'performanceLevel', entity.performanceLevel, PERFORMANCE_LEVELS);
|
||||
assertEnumValue(entityName, 'specialization', entity.specialization, SPECIALIZATIONS);
|
||||
assertNonEmptyString(entityName, 'region', entity.region);
|
||||
assertArray(entityName, 'languages', entity.languages);
|
||||
assertInteger(entityName, 'totalWins', entity.totalWins);
|
||||
assertInteger(entityName, 'totalRaces', entity.totalRaces);
|
||||
assertInteger(entityName, 'rating', entity.rating);
|
||||
|
||||
const result: TeamStats = {
|
||||
logoUrl: entity.logoUrl,
|
||||
performanceLevel: entity.performanceLevel as 'beginner' | 'intermediate' | 'advanced' | 'pro',
|
||||
specialization: entity.specialization as 'endurance' | 'sprint' | 'mixed',
|
||||
region: entity.region,
|
||||
languages: entity.languages,
|
||||
totalWins: entity.totalWins,
|
||||
totalRaces: entity.totalRaces,
|
||||
rating: entity.rating,
|
||||
};
|
||||
|
||||
if (entity.category !== null && entity.category !== undefined) {
|
||||
result.category = entity.category;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ITeamStatsRepository, TeamStats } from '@core/racing/domain/repositories/ITeamStatsRepository';
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import { TeamStatsOrmEntity } from '../entities/TeamStatsOrmEntity';
|
||||
import { TeamStatsOrmMapper } from '../mappers/TeamStatsOrmMapper';
|
||||
|
||||
export class TypeOrmTeamStatsRepository implements ITeamStatsRepository {
|
||||
constructor(
|
||||
private readonly repo: Repository<TeamStatsOrmEntity>,
|
||||
private readonly mapper: TeamStatsOrmMapper,
|
||||
) {}
|
||||
|
||||
async getTeamStats(teamId: string): Promise<TeamStats | null> {
|
||||
const entity = await this.repo.findOne({ where: { teamId } });
|
||||
return entity ? this.mapper.toDomain(entity) : null;
|
||||
}
|
||||
|
||||
async saveTeamStats(teamId: string, stats: TeamStats): Promise<void> {
|
||||
const entity = this.mapper.toOrmEntity(teamId, stats);
|
||||
await this.repo.save(entity);
|
||||
}
|
||||
|
||||
async getAllStats(): Promise<Map<string, TeamStats>> {
|
||||
const entities = await this.repo.find();
|
||||
const statsMap = new Map<string, TeamStats>();
|
||||
|
||||
for (const entity of entities) {
|
||||
statsMap.set(entity.teamId, this.mapper.toDomain(entity));
|
||||
}
|
||||
|
||||
return statsMap;
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
await this.repo.clear();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user