seed data

This commit is contained in:
2025-12-30 18:33:15 +01:00
parent 83371ea839
commit 92226800df
306 changed files with 1753 additions and 501 deletions

View File

@@ -78,6 +78,10 @@ export class SeedRacingData {
return process.env.DATABASE_URL ? 'postgres' : 'inmemory'; 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> { async execute(): Promise<void> {
const existingDrivers = await this.seedDeps.driverRepository.findAll(); const existingDrivers = await this.seedDeps.driverRepository.findAll();
const persistence = this.getApiPersistence(); const persistence = this.getApiPersistence();
@@ -428,7 +432,7 @@ export class SeedRacingData {
private calculateTeamStats(team: Team, results: Result[], drivers: Driver[]): TeamStats { private calculateTeamStats(team: Team, results: Result[], drivers: Driver[]): TeamStats {
const wins = results.filter(r => r.position.toNumber() === 1).length; const wins = results.filter(r => r.position.toNumber() === 1).length;
const totalRaces = results.length; const totalRaces = results.length;
// Calculate rating // Calculate rating
const baseRating = 1000; const baseRating = 1000;
const winBonus = wins * 50; const winBonus = wins * 50;
@@ -462,7 +466,7 @@ export class SeedRacingData {
}))); })));
return { return {
logoUrl: `https://api.gridpilot.io/media/team/${team.id}/logo.png`, logoUrl: `${this.getMediaBaseUrl()}/api/media/teams/${team.id}/logo`,
performanceLevel, performanceLevel,
specialization, specialization,
region, region,
@@ -482,21 +486,22 @@ export class SeedRacingData {
} }
private async seedMediaAssets(seed: any): Promise<void> { 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) { for (const driver of seed.drivers) {
const avatarUrl = `https://api.gridpilot.io/media/driver/${driver.id}/avatar.png`; const avatarUrl = this.getDriverAvatarUrl(driver.id);
// Type assertion to access the helper method
const mediaRepo = this.seedDeps.mediaRepository as any; const mediaRepo = this.seedDeps.mediaRepository as any;
if (mediaRepo.setDriverAvatar) { if (mediaRepo.setDriverAvatar) {
mediaRepo.setDriverAvatar(driver.id, avatarUrl); mediaRepo.setDriverAvatar(driver.id, avatarUrl);
} }
} }
// Seed team logos // Seed team logos using API routes
for (const team of seed.teams) { 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; const mediaRepo = this.seedDeps.mediaRepository as any;
if (mediaRepo.setTeamLogo) { if (mediaRepo.setTeamLogo) {
mediaRepo.setTeamLogo(team.id, logoUrl); mediaRepo.setTeamLogo(team.id, logoUrl);
@@ -505,8 +510,8 @@ export class SeedRacingData {
// Seed track images // Seed track images
for (const track of seed.tracks || []) { 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; const mediaRepo = this.seedDeps.mediaRepository as any;
if (mediaRepo.setTrackImage) { if (mediaRepo.setTrackImage) {
mediaRepo.setTrackImage(track.id, trackImageUrl); mediaRepo.setTrackImage(track.id, trackImageUrl);
@@ -516,8 +521,8 @@ export class SeedRacingData {
// Seed category icons (if categories exist) // Seed category icons (if categories exist)
const categories = ['beginner', 'intermediate', 'advanced', 'pro', 'endurance', 'sprint']; const categories = ['beginner', 'intermediate', 'advanced', 'pro', 'endurance', 'sprint'];
for (const category of categories) { 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; const mediaRepo = this.seedDeps.mediaRepository as any;
if (mediaRepo.setCategoryIcon) { if (mediaRepo.setCategoryIcon) {
mediaRepo.setCategoryIcon(category, iconUrl); mediaRepo.setCategoryIcon(category, iconUrl);
@@ -526,8 +531,8 @@ export class SeedRacingData {
// Seed sponsor logos // Seed sponsor logos
for (const sponsor of seed.sponsors || []) { 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; const mediaRepo = this.seedDeps.mediaRepository as any;
if (mediaRepo.setSponsorLogo) { if (mediaRepo.setSponsorLogo) {
mediaRepo.setSponsorLogo(sponsor.id, logoUrl); 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`); 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> { private async clearExistingRacingData(): Promise<void> {
// Get all existing drivers // Get all existing drivers
const drivers = await this.seedDeps.driverRepository.findAll(); const drivers = await this.seedDeps.driverRepository.findAll();

View File

@@ -25,8 +25,51 @@ export class RacingDriverFactory {
private readonly persistence: 'postgres' | 'inmemory' = 'inmemory', 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[] { 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 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) => { return Array.from({ length: this.driverCount }, (_, idx) => {
const i = idx + 1; const i = idx + 1;
@@ -53,6 +96,9 @@ export class RacingDriverFactory {
joinedAt = faker.date.past({ years: 2, refDate: this.baseDate }); joinedAt = faker.date.past({ years: 2, refDate: this.baseDate });
} }
// Assign category - use all available categories
const category = faker.helpers.arrayElement(categories);
const driverData: { const driverData: {
id: string; id: string;
iracingId: string; iracingId: string;
@@ -60,12 +106,14 @@ export class RacingDriverFactory {
country: string; country: string;
bio?: string; bio?: string;
joinedAt?: Date; joinedAt?: Date;
category?: string;
} = { } = {
id: seedId(`driver-${i}`, this.persistence), id: seedId(`driver-${i}`, this.persistence),
iracingId: String(100000 + i), iracingId: String(100000 + i),
name: faker.person.fullName(), name: faker.person.fullName(),
country: faker.helpers.arrayElement(countries), country: faker.helpers.arrayElement(countries),
joinedAt, joinedAt,
category,
}; };
if (hasBio) { if (hasBio) {

View File

@@ -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 { Driver } from '@core/racing/domain/entities/Driver';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import { seedId } from './SeedIdHelper'; import { seedId } from './SeedIdHelper';
@@ -11,7 +11,7 @@ export class RacingLeagueFactory {
) {} ) {}
create(): League[] { create(): League[] {
const leagueCount = 30; const leagueCount = 120; // Expanded to 100+ leagues
// Create diverse league configurations covering ALL enum combinations // Create diverse league configurations covering ALL enum combinations
// Points systems: f1-2024, indycar, custom (3) // 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) // 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 // Total combinations: 3 * 2 * 2 * 6 = 72, but we'll sample 30 covering extremes
// Category types for leagues
const leagueConfigs = [ const leagueConfigs = [
// 1-5: Ranked, F1-2024, various stewarding // 1-5: Ranked, F1-2024, various stewarding
{ {
@@ -277,7 +279,8 @@ export class RacingLeagueFactory {
return Array.from({ length: leagueCount }, (_, idx) => { return Array.from({ length: leagueCount }, (_, idx) => {
const i = idx + 1; const i = idx + 1;
const owner = faker.helpers.arrayElement(this.drivers); const owner = faker.helpers.arrayElement(this.drivers);
const config = leagueConfigs[idx]!; // Cycle through the 30 configs for variety
const config = leagueConfigs[idx % 30]!;
const createdAt = const createdAt =
// Ensure some "New" leagues (created within 7 days) so `/leagues` featured categories are populated. // Ensure some "New" leagues (created within 7 days) so `/leagues` featured categories are populated.
@@ -310,33 +313,29 @@ export class RacingLeagueFactory {
} }
} }
const leagueData: { // Determine category based on scoring configuration
id: string; let category: string | undefined;
name: string; if (config.pointsSystem === 'f1-2024') {
description: string; if (config.qualifyingFormat === 'single-lap') {
ownerId: string; category = 'driver';
settings: { } else {
pointsSystem: 'f1-2024' | 'indycar' | 'custom'; category = 'team';
maxDrivers: number; }
sessionDuration: number; } else if (config.pointsSystem === 'indycar') {
qualifyingFormat: 'open' | 'single-lap'; category = 'nations';
visibility?: 'ranked' | 'unranked'; } else if (config.pointsSystem === 'custom') {
stewarding?: { category = 'trophy';
decisionMode: 'admin_only' | 'steward_decides' | 'steward_vote' | 'member_vote' | 'steward_veto' | 'member_veto'; }
requiredVotes?: number;
requireDefense?: boolean; // Override some leagues to have endurance or sprint categories
defenseTimeLimit?: number; if (idx % 8 === 0) {
voteTimeLimit?: number; category = 'endurance';
protestDeadlineHours?: number; } else if (idx % 7 === 0) {
stewardingClosesHours?: number; category = 'sprint';
notifyAccusedOnProtest?: boolean; }
notifyOnVoteRequired?: boolean;
}; // Build the league data object
}; const leagueData = {
createdAt: Date;
socialLinks?: { discordUrl?: string; youtubeUrl?: string; websiteUrl?: string };
participantCount?: number;
} = {
id: seedId(`league-${i}`, this.persistence), id: seedId(`league-${i}`, this.persistence),
name: faker.company.name() + ' Racing League', name: faker.company.name() + ' Racing League',
description: faker.lorem.sentences(2), description: faker.lorem.sentences(2),
@@ -356,6 +355,7 @@ export class RacingLeagueFactory {
notifyOnVoteRequired: true, notifyOnVoteRequired: true,
}, },
}, },
category,
createdAt, createdAt,
participantCount, participantCount,
}; };
@@ -374,11 +374,37 @@ export class RacingLeagueFactory {
if (type === 'website') socialLinks.websiteUrl = faker.internet.url(); 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) { if (Object.keys(socialLinks).length > 0) {
leagueData.socialLinks = socialLinks; finalLeagueData.socialLinks = socialLinks;
} }
return League.create(leagueData); return League.create(finalLeagueData);
}); });
} }
} }

View File

@@ -14,6 +14,7 @@ export interface TeamStats {
totalWins: number; totalWins: number;
totalRaces: number; totalRaces: number;
rating: number; rating: number;
category?: string;
} }
export class RacingTeamFactory { export class RacingTeamFactory {
@@ -33,6 +34,9 @@ export class RacingTeamFactory {
{ min: 0, max: 3 }, { min: 0, max: 3 },
); );
// 30-50% of teams are recruiting
const isRecruiting = faker.datatype.boolean({ probability: 0.4 });
return Team.create({ return Team.create({
id: seedId(`team-${i}`, this.persistence), id: seedId(`team-${i}`, this.persistence),
name: faker.company.name() + ' Racing', name: faker.company.name() + ' Racing',
@@ -40,6 +44,7 @@ export class RacingTeamFactory {
description: faker.lorem.sentences(2), description: faker.lorem.sentences(2),
ownerId: owner.id, ownerId: owner.id,
leagues: teamLeagues, leagues: teamLeagues,
isRecruiting,
createdAt: faker.date.past({ years: 2, refDate: this.baseDate }), createdAt: faker.date.past({ years: 2, refDate: this.baseDate }),
}); });
}); });
@@ -256,6 +261,10 @@ export class RacingTeamFactory {
specialization = 'mixed'; 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 // Generate region and languages
const region = faker.helpers.arrayElement(regions); const region = faker.helpers.arrayElement(regions);
const languageCount = faker.number.int({ min: 1, max: 3 }); const languageCount = faker.number.int({ min: 1, max: 3 });
@@ -273,6 +282,7 @@ export class RacingTeamFactory {
totalWins, totalWins,
totalRaces, totalRaces,
rating, rating,
category,
}); });
}); });

View File

@@ -8,42 +8,21 @@ export class InMemoryImageServiceAdapter implements IImageServicePort {
getDriverAvatar(driverId: string): string { getDriverAvatar(driverId: string): string {
this.logger.debug(`[InMemoryImageServiceAdapter] Getting avatar for driver: ${driverId}`); this.logger.debug(`[InMemoryImageServiceAdapter] Getting avatar for driver: ${driverId}`);
const driverNumber = Number(driverId.replace('driver-', '')); return `/media/avatar/${driverId}`;
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];
} }
getTeamLogo(teamId: string): string { getTeamLogo(teamId: string): string {
this.logger.debug(`[InMemoryImageServiceAdapter] Getting logo for team: ${teamId}`); this.logger.debug(`[InMemoryImageServiceAdapter] Getting logo for team: ${teamId}`);
const teamNumber = Number(teamId.replace('team-', '')); return `/media/teams/${teamId}/logo`;
const index = Number.isFinite(teamNumber) ? teamNumber % 6 : 0;
const logos = [
'/images/ff1600.jpeg',
'/images/header.jpeg',
'/images/avatars/male-default-avatar.jpg',
'/images/avatars/female-default-avatar.jpeg',
'/images/avatars/neutral-default-avatar.jpeg',
'/images/leagues/placeholder-cover.svg',
] as const;
return logos[index] ?? logos[0];
} }
getLeagueCover(leagueId: string): string { getLeagueCover(leagueId: string): string {
this.logger.debug(`[InMemoryImageServiceAdapter] Getting cover for league: ${leagueId}`); this.logger.debug(`[InMemoryImageServiceAdapter] Getting cover for league: ${leagueId}`);
return '/images/header.jpeg'; return `/media/leagues/${leagueId}/cover`;
} }
getLeagueLogo(leagueId: string): string { getLeagueLogo(leagueId: string): string {
this.logger.debug(`[InMemoryImageServiceAdapter] Getting logo for league: ${leagueId}`); this.logger.debug(`[InMemoryImageServiceAdapter] Getting logo for league: ${leagueId}`);
return '/images/ff1600.jpeg'; return `/media/leagues/${leagueId}/logo`;
} }
} }

View File

@@ -1,32 +1,18 @@
/** import type { Logger } from '@core/shared/application/Logger';
* Infrastructure Adapter: InMemoryTeamStatsRepository
*
* In-memory implementation of ITeamStatsRepository.
* Stores computed team statistics for caching and frontend queries.
*/
import type { ITeamStatsRepository, TeamStats } from '@core/racing/domain/repositories/ITeamStatsRepository'; import type { ITeamStatsRepository, TeamStats } from '@core/racing/domain/repositories/ITeamStatsRepository';
import type { Logger } from '@core/shared/application';
export class InMemoryTeamStatsRepository implements ITeamStatsRepository { export class InMemoryTeamStatsRepository implements ITeamStatsRepository {
private stats = new Map<string, TeamStats>(); private readonly stats = new Map<string, TeamStats>();
constructor(private readonly logger: Logger) { constructor(private readonly logger: Logger) {}
this.logger.info('[InMemoryTeamStatsRepository] Initialized.');
}
async getTeamStats(teamId: string): Promise<TeamStats | null> { async getTeamStats(teamId: string): Promise<TeamStats | null> {
this.logger.debug(`[InMemoryTeamStatsRepository] Getting stats for team: ${teamId}`); this.logger.debug(`[InMemoryTeamStatsRepository] Getting stats for team: ${teamId}`);
return this.stats.get(teamId) ?? null; 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;
} }
async saveTeamStats(teamId: string, stats: TeamStats): Promise<void> { 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); this.stats.set(teamId, stats);
} }
@@ -36,7 +22,7 @@ export class InMemoryTeamStatsRepository implements ITeamStatsRepository {
} }
async clear(): Promise<void> { async clear(): Promise<void> {
this.logger.info('[InMemoryTeamStatsRepository] Clearing all stats'); this.logger.debug('[InMemoryTeamStatsRepository] Clearing all stats');
this.stats.clear(); this.stats.clear();
} }
} }

View File

@@ -19,4 +19,7 @@ export class DriverOrmEntity {
@Column({ type: 'timestamptz' }) @Column({ type: 'timestamptz' })
joinedAt!: Date; joinedAt!: Date;
@Column({ type: 'text', nullable: true })
category!: string | null;
} }

View File

@@ -19,6 +19,9 @@ export class LeagueOrmEntity {
@Column({ type: 'jsonb' }) @Column({ type: 'jsonb' })
settings!: SerializedLeagueSettings; settings!: SerializedLeagueSettings;
@Column({ type: 'text', nullable: true })
category!: string | null;
@CreateDateColumn({ type: 'timestamptz' }) @CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date; createdAt!: Date;

View File

@@ -20,6 +20,12 @@ export class TeamOrmEntity {
@Column({ type: 'uuid', array: true }) @Column({ type: 'uuid', array: true })
leagues!: string[]; leagues!: string[];
@Column({ type: 'text', nullable: true })
category!: string | null;
@Column({ type: 'boolean', default: false })
isRecruiting!: boolean;
@Column({ type: 'timestamptz' }) @Column({ type: 'timestamptz' })
createdAt!: Date; createdAt!: Date;
} }

View File

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

View File

@@ -12,6 +12,7 @@ export class DriverOrmMapper {
entity.country = domain.country.toString(); entity.country = domain.country.toString();
entity.bio = domain.bio?.toString() ?? null; entity.bio = domain.bio?.toString() ?? null;
entity.joinedAt = domain.joinedAt.toDate(); entity.joinedAt = domain.joinedAt.toDate();
entity.category = domain.category ?? null;
return entity; return entity;
} }
@@ -24,14 +25,31 @@ export class DriverOrmMapper {
assertNonEmptyString(entityName, 'country', entity.country); assertNonEmptyString(entityName, 'country', entity.country);
assertDate(entityName, 'joinedAt', entity.joinedAt); assertDate(entityName, 'joinedAt', entity.joinedAt);
assertOptionalStringOrNull(entityName, 'bio', entity.bio); 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, id: entity.id,
iracingId: entity.iracingId, iracingId: entity.iracingId,
name: entity.name, name: entity.name,
country: entity.country, country: entity.country,
...(entity.bio !== null && entity.bio !== undefined ? { bio: entity.bio } : {}),
joinedAt: entity.joinedAt, 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);
} }
} }

View File

@@ -155,6 +155,7 @@ export class LeagueOrmMapper {
entity.description = domain.description.toString(); entity.description = domain.description.toString();
entity.ownerId = domain.ownerId.toString(); entity.ownerId = domain.ownerId.toString();
entity.settings = serializeLeagueSettings(domain.settings); entity.settings = serializeLeagueSettings(domain.settings);
entity.category = domain.category ?? null;
entity.createdAt = domain.createdAt.toDate(); entity.createdAt = domain.createdAt.toDate();
entity.participantCount = domain.getParticipantCount(); entity.participantCount = domain.getParticipantCount();
entity.discordUrl = domain.socialLinks?.discordUrl ?? null; entity.discordUrl = domain.socialLinks?.discordUrl ?? null;
@@ -172,6 +173,7 @@ export class LeagueOrmMapper {
description: entity.description, description: entity.description,
ownerId: entity.ownerId, ownerId: entity.ownerId,
settings, settings,
category: entity.category ?? undefined,
createdAt: entity.createdAt, createdAt: entity.createdAt,
participantCount: entity.participantCount, participantCount: entity.participantCount,
...(entity.discordUrl || entity.youtubeUrl || entity.websiteUrl ...(entity.discordUrl || entity.youtubeUrl || entity.websiteUrl

View File

@@ -25,6 +25,8 @@ export class TeamOrmMapper {
entity.description = domain.description.toString(); entity.description = domain.description.toString();
entity.ownerId = domain.ownerId.toString(); entity.ownerId = domain.ownerId.toString();
entity.leagues = domain.leagues.map((l) => l.toString()); entity.leagues = domain.leagues.map((l) => l.toString());
entity.category = domain.category ?? null;
entity.isRecruiting = domain.isRecruiting;
entity.createdAt = domain.createdAt.toDate(); entity.createdAt = domain.createdAt.toDate();
return entity; return entity;
} }
@@ -45,15 +47,32 @@ export class TeamOrmMapper {
} }
try { 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, id: entity.id,
name: entity.name, name: entity.name,
tag: entity.tag, tag: entity.tag,
description: entity.description, description: entity.description,
ownerId: entity.ownerId, ownerId: entity.ownerId,
leagues: entity.leagues, leagues: entity.leagues,
isRecruiting: entity.isRecruiting ?? false,
createdAt: entity.createdAt, createdAt: entity.createdAt,
}); };
if (entity.category !== null && entity.category !== undefined) {
rehydrateProps.category = entity.category;
}
return Team.rehydrate(rehydrateProps);
} catch { } catch {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: '__root', reason: 'invalid_shape' }); throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: '__root', reason: 'invalid_shape' });
} }

View File

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

View File

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

View File

@@ -2710,6 +2710,21 @@
}, },
"joinedAt": { "joinedAt": {
"type": "string" "type": "string"
},
"rating": {
"type": "number"
},
"experienceLevel": {
"type": "string"
},
"wins": {
"type": "number"
},
"podiums": {
"type": "number"
},
"totalRaces": {
"type": "number"
} }
}, },
"required": [ "required": [
@@ -6607,6 +6622,21 @@
"items": { "items": {
"type": "string" "type": "string"
} }
},
"totalWins": {
"type": "number"
},
"totalRaces": {
"type": "number"
},
"performanceLevel": {
"type": "string"
},
"logoUrl": {
"type": "string"
},
"rating": {
"type": "number"
} }
}, },
"required": [ "required": [

View File

@@ -18,6 +18,11 @@ export class DashboardDriverSummaryDTO {
@IsString() @IsString()
avatarUrl!: string; avatarUrl!: string;
@ApiProperty({ nullable: true })
@IsOptional()
@IsString()
category?: string | null;
@ApiProperty({ nullable: true }) @ApiProperty({ nullable: true })
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()

View File

@@ -20,6 +20,11 @@ export class DashboardDriverSummaryDTO {
@IsString() @IsString()
avatarUrl?: string | null; avatarUrl?: string | null;
@ApiProperty({ nullable: true })
@IsOptional()
@IsString()
category?: string | null;
@ApiProperty({ nullable: true }) @ApiProperty({ nullable: true })
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()

View File

@@ -23,6 +23,7 @@ export class DashboardOverviewPresenter implements UseCaseOutputPort<DashboardOv
name: String(data.currentDriver.driver.name), name: String(data.currentDriver.driver.name),
country: String(data.currentDriver.driver.country), country: String(data.currentDriver.driver.country),
avatarUrl: data.currentDriver.avatarUrl, avatarUrl: data.currentDriver.avatarUrl,
category: data.currentDriver.driver.category ?? null,
rating: data.currentDriver.rating, rating: data.currentDriver.rating,
globalRank: data.currentDriver.globalRank, globalRank: data.currentDriver.globalRank,
totalRaces: data.currentDriver.totalRaces, totalRaces: data.currentDriver.totalRaces,

View File

@@ -18,4 +18,7 @@ export class DriverDTO {
@ApiProperty() @ApiProperty()
joinedAt!: string; joinedAt!: string;
@ApiProperty({ required: false })
category?: string;
} }

View File

@@ -13,6 +13,9 @@ export class DriverLeaderboardItemDTO {
@ApiProperty() @ApiProperty()
skillLevel!: string; // Assuming skillLevel is a string like 'Rookie', 'Pro', etc. skillLevel!: string; // Assuming skillLevel is a string like 'Rookie', 'Pro', etc.
@ApiProperty({ required: false })
category?: string;
@ApiProperty() @ApiProperty()
nationality!: string; nationality!: string;

View File

@@ -19,6 +19,9 @@ export class DriverProfileDriverSummaryDTO {
@ApiProperty() @ApiProperty()
joinedAt!: string; joinedAt!: string;
@ApiProperty({ nullable: true })
category!: string | null;
@ApiProperty({ nullable: true }) @ApiProperty({ nullable: true })
rating!: number | null; rating!: number | null;

View File

@@ -19,6 +19,9 @@ export class GetDriverOutputDTO {
@ApiProperty() @ApiProperty()
joinedAt!: string; joinedAt!: string;
@ApiProperty({ required: false })
category?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
rating?: number; rating?: number;

View File

@@ -33,6 +33,7 @@ export class DriverPresenter {
country: driver.country.toString(), country: driver.country.toString(),
joinedAt: driver.joinedAt.toDate().toISOString(), joinedAt: driver.joinedAt.toDate().toISOString(),
...(driver.bio ? { bio: driver.bio.toString() } : {}), ...(driver.bio ? { bio: driver.bio.toString() } : {}),
...(driver.category ? { category: driver.category } : {}),
// Add stats fields // Add stats fields
...(stats ? { ...(stats ? {
rating: stats.rating, rating: stats.rating,

View File

@@ -18,6 +18,7 @@ export class DriverProfilePresenter
avatarUrl: this.getAvatarUrl(result.driverInfo.driver.id) || '', avatarUrl: this.getAvatarUrl(result.driverInfo.driver.id) || '',
iracingId: result.driverInfo.driver.iracingId.toString(), iracingId: result.driverInfo.driver.iracingId.toString(),
joinedAt: result.driverInfo.driver.joinedAt.toDate().toISOString(), joinedAt: result.driverInfo.driver.joinedAt.toDate().toISOString(),
category: result.driverInfo.driver.category || null,
rating: result.driverInfo.rating, rating: result.driverInfo.rating,
globalRank: result.driverInfo.globalRank, globalRank: result.driverInfo.globalRank,
consistency: result.driverInfo.consistency, consistency: result.driverInfo.consistency,

View File

@@ -13,6 +13,7 @@ export class DriversLeaderboardPresenter {
name: item.driver.name.toString(), name: item.driver.name.toString(),
rating: item.rating, rating: item.rating,
skillLevel: item.skillLevel, skillLevel: item.skillLevel,
...(item.driver.category !== undefined ? { category: item.driver.category } : {}),
nationality: item.driver.country.toString(), nationality: item.driver.country.toString(),
racesCompleted: item.racesCompleted, racesCompleted: item.racesCompleted,
wins: item.wins, wins: item.wins,

View File

@@ -95,6 +95,11 @@ export class LeagueWithCapacityAndScoringDTO {
@IsNumber() @IsNumber()
usedSlots!: number; usedSlots!: number;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
category?: string;
@ApiProperty({ required: false, nullable: true, type: LeagueCapacityAndScoringSocialLinksDTO }) @ApiProperty({ required: false, nullable: true, type: LeagueCapacityAndScoringSocialLinksDTO })
@IsOptional() @IsOptional()
@ValidateNested() @ValidateNested()

View File

@@ -32,6 +32,7 @@ export class AllLeaguesWithCapacityAndScoringPresenter
: {}), : {}),
}, },
usedSlots: summary.currentDrivers, usedSlots: summary.currentDrivers,
...(summary.league.category ? { category: summary.league.category } : {}),
...mapSocialLinks(summary.league.socialLinks), ...mapSocialLinks(summary.league.socialLinks),
...(summary.scoringConfig && summary.game && summary.preset ...(summary.scoringConfig && summary.game && summary.preset
? { ? {

View File

@@ -91,6 +91,70 @@ function buildLeagueCoverSvg(leagueId: string): string {
</svg>`; </svg>`;
} }
function buildDriverAvatarSvg(driverId: string): string {
const hue = hashToHue(driverId);
const initials = deriveLeagueLabel(driverId);
const bg = `hsl(${hue} 70% 38%)`;
const border = `hsl(${hue} 70% 28%)`;
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="Driver avatar">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${bg}"/>
<stop offset="100%" stop-color="hsl(${hue} 80% 46%)"/>
</linearGradient>
</defs>
<circle cx="48" cy="48" r="44" fill="url(#g)" stroke="${border}" stroke-width="3"/>
<text x="48" y="56" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial" font-size="32" font-weight="800" text-anchor="middle" fill="white">${initials}</text>
</svg>`;
}
function buildTrackImageSvg(trackId: string): string {
const hue = hashToHue(trackId);
const label = escapeXml(deriveLeagueLabel(trackId));
const bg1 = `hsl(${hue} 70% 28%)`;
const bg2 = `hsl(${(hue + 20) % 360} 65% 35%)`;
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="400" viewBox="0 0 1200 400" role="img" aria-label="Track image">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${bg1}"/>
<stop offset="100%" stop-color="${bg2}"/>
</linearGradient>
</defs>
<rect width="1200" height="400" fill="url(#bg)"/>
<!-- Track outline -->
<path d="M 200 200 Q 400 100 600 200 T 1000 200" fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="8" stroke-linecap="round"/>
<path d="M 200 220 Q 400 120 600 220 T 1000 220" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="6" stroke-linecap="round"/>
<text x="64" y="110" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial" font-size="40" font-weight="800" fill="rgba(255,255,255,0.92)">Track ${label}</text>
<text x="64" y="165" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial" font-size="22" font-weight="600" fill="rgba(255,255,255,0.75)">${escapeXml(trackId)}</text>
</svg>`;
}
function buildCategoryIconSvg(categoryId: string): string {
const hue = hashToHue(categoryId);
const label = escapeXml(categoryId.substring(0, 3).toUpperCase());
const bg = `hsl(${hue} 70% 38%)`;
const border = `hsl(${hue} 70% 28%)`;
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-label="Category icon">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${bg}"/>
<stop offset="100%" stop-color="hsl(${hue} 80% 46%)"/>
</linearGradient>
</defs>
<rect x="2" y="2" width="60" height="60" rx="12" fill="url(#g)" stroke="${border}" stroke-width="2"/>
<text x="32" y="40" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial" font-size="22" font-weight="800" text-anchor="middle" fill="white">${label}</text>
</svg>`;
}
@ApiTags('media') @ApiTags('media')
@Controller('media') @Controller('media')
export class MediaController { export class MediaController {
@@ -159,6 +223,132 @@ export class MediaController {
res.status(HttpStatus.OK).send(svg); res.status(HttpStatus.OK).send(svg);
} }
@Public()
@Get('teams/:teamId/logo')
@ApiOperation({ summary: 'Get team logo (placeholder)' })
@ApiParam({ name: 'teamId', description: 'Team ID' })
async getTeamLogo(
@Param('teamId') teamId: string,
@Res() res: Response,
): Promise<void> {
const svg = buildLeagueLogoSvg(teamId);
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.status(HttpStatus.OK).send(svg);
}
@Public()
@Get('team/:teamId/logo')
@ApiOperation({ summary: 'Get team logo (singular path)' })
@ApiParam({ name: 'teamId', description: 'Team ID' })
async getTeamLogoSingular(
@Param('teamId') teamId: string,
@Res() res: Response,
): Promise<void> {
const svg = buildLeagueLogoSvg(teamId);
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.status(HttpStatus.OK).send(svg);
}
@Public()
@Get('team/:teamId/logo.png')
@ApiOperation({ summary: 'Get team logo with .png extension' })
@ApiParam({ name: 'teamId', description: 'Team ID' })
async getTeamLogoPng(
@Param('teamId') teamId: string,
@Res() res: Response,
): Promise<void> {
const svg = buildLeagueLogoSvg(teamId);
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.status(HttpStatus.OK).send(svg);
}
@Public()
@Get('teams/:teamId/cover')
@ApiOperation({ summary: 'Get team cover (placeholder)' })
@ApiParam({ name: 'teamId', description: 'Team ID' })
async getTeamCover(
@Param('teamId') teamId: string,
@Res() res: Response,
): Promise<void> {
const svg = buildLeagueCoverSvg(teamId);
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.status(HttpStatus.OK).send(svg);
}
@Public()
@Get('drivers/:driverId/avatar')
@ApiOperation({ summary: 'Get driver avatar (placeholder)' })
@ApiParam({ name: 'driverId', description: 'Driver ID' })
async getDriverAvatar(
@Param('driverId') driverId: string,
@Res() res: Response,
): Promise<void> {
const svg = buildDriverAvatarSvg(driverId);
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.status(HttpStatus.OK).send(svg);
}
@Public()
@Get('avatar/:driverId')
@ApiOperation({ summary: 'Get driver avatar (alternative path)' })
@ApiParam({ name: 'driverId', description: 'Driver ID' })
async getDriverAvatarAlt(
@Param('driverId') driverId: string,
@Res() res: Response,
): Promise<void> {
const svg = buildDriverAvatarSvg(driverId);
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.status(HttpStatus.OK).send(svg);
}
@Public()
@Get('tracks/:trackId/image')
@ApiOperation({ summary: 'Get track image (placeholder)' })
@ApiParam({ name: 'trackId', description: 'Track ID' })
async getTrackImage(
@Param('trackId') trackId: string,
@Res() res: Response,
): Promise<void> {
const svg = buildTrackImageSvg(trackId);
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.status(HttpStatus.OK).send(svg);
}
@Public()
@Get('categories/:categoryId/icon')
@ApiOperation({ summary: 'Get category icon (placeholder)' })
@ApiParam({ name: 'categoryId', description: 'Category ID' })
async getCategoryIcon(
@Param('categoryId') categoryId: string,
@Res() res: Response,
): Promise<void> {
const svg = buildCategoryIconSvg(categoryId);
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.status(HttpStatus.OK).send(svg);
}
@Public()
@Get('sponsors/:sponsorId/logo')
@ApiOperation({ summary: 'Get sponsor logo (placeholder)' })
@ApiParam({ name: 'sponsorId', description: 'Sponsor ID' })
async getSponsorLogo(
@Param('sponsorId') sponsorId: string,
@Res() res: Response,
): Promise<void> {
const svg = buildLeagueLogoSvg(sponsorId);
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.status(HttpStatus.OK).send(svg);
}
@Public() @Public()
@Get(':mediaId') @Get(':mediaId')
@ApiOperation({ summary: 'Get media by ID' }) @ApiOperation({ summary: 'Get media by ID' })
@@ -237,4 +427,4 @@ export class MediaController {
res.status(HttpStatus.BAD_REQUEST).json(dto); res.status(HttpStatus.BAD_REQUEST).json(dto);
} }
} }
} }

View File

@@ -58,7 +58,7 @@ describe('TeamController', () => {
it('should return team details', async () => { it('should return team details', async () => {
const teamId = 'team-123'; const teamId = 'team-123';
const userId = 'user-456'; const userId = 'user-456';
const result = { team: { id: teamId, name: 'Team', tag: 'TAG', description: 'Desc', ownerId: 'owner', leagues: [] }, membership: null, canManage: false }; const result = { team: { id: teamId, name: 'Team', tag: 'TAG', description: 'Desc', ownerId: 'owner', leagues: [], isRecruiting: false }, membership: null, canManage: false };
service.getDetails.mockResolvedValue(result); service.getDetails.mockResolvedValue(result);
const mockReq = { user: { userId } } as any; const mockReq = { user: { userId } } as any;
@@ -132,7 +132,7 @@ describe('TeamController', () => {
describe('getDriverTeam', () => { describe('getDriverTeam', () => {
it('should return driver team', async () => { it('should return driver team', async () => {
const driverId = 'driver-123'; const driverId = 'driver-123';
const result = { team: { id: 'team-456', name: 'Team', tag: 'TAG', description: 'Desc', ownerId: 'owner', leagues: [] }, membership: { role: 'member' as const, joinedAt: '2023-01-01', isActive: true }, isOwner: false, canManage: false }; const result = { team: { id: 'team-456', name: 'Team', tag: 'TAG', description: 'Desc', ownerId: 'owner', leagues: [], isRecruiting: false }, membership: { role: 'member' as const, joinedAt: '2023-01-01', isActive: true }, isOwner: false, canManage: false };
service.getDriverTeam.mockResolvedValue(result); service.getDriverTeam.mockResolvedValue(result);
const response = await controller.getDriverTeam(driverId); const response = await controller.getDriverTeam(driverId);

View File

@@ -1,6 +1,6 @@
import { Provider } from '@nestjs/common'; import { Provider } from '@nestjs/common';
import { IMAGE_SERVICE_TOKEN, LOGGER_TOKEN, TEAM_STATS_REPOSITORY_TOKEN, MEDIA_REPOSITORY_TOKEN } from './TeamTokens'; import { IMAGE_SERVICE_TOKEN, LOGGER_TOKEN, MEDIA_REPOSITORY_TOKEN } from './TeamTokens';
export { export {
TEAM_REPOSITORY_TOKEN, TEAM_REPOSITORY_TOKEN,
@@ -8,18 +8,15 @@ export {
DRIVER_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN,
IMAGE_SERVICE_TOKEN, IMAGE_SERVICE_TOKEN,
LOGGER_TOKEN, LOGGER_TOKEN,
TEAM_STATS_REPOSITORY_TOKEN,
MEDIA_REPOSITORY_TOKEN, MEDIA_REPOSITORY_TOKEN,
} from './TeamTokens'; } from './TeamTokens';
// Import core interfaces // Import core interfaces
import type { Logger } from '@core/shared/application/Logger'; import type { Logger } from '@core/shared/application/Logger';
import type { ITeamStatsRepository } from '@core/racing/domain/repositories/ITeamStatsRepository';
// Import concrete in-memory implementations // Import concrete in-memory implementations
import { InMemoryImageServiceAdapter } from '@adapters/media/ports/InMemoryImageServiceAdapter'; import { InMemoryImageServiceAdapter } from '@adapters/media/ports/InMemoryImageServiceAdapter';
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger'; import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
import { InMemoryTeamStatsRepository } from '@adapters/racing/persistence/inmemory/InMemoryTeamStatsRepository';
import { InMemoryMediaRepository } from '@adapters/racing/persistence/media/InMemoryMediaRepository'; import { InMemoryMediaRepository } from '@adapters/racing/persistence/media/InMemoryMediaRepository';
// Import presenters // Import presenters
@@ -35,11 +32,6 @@ export const TeamProviders: Provider[] = [
provide: LOGGER_TOKEN, provide: LOGGER_TOKEN,
useClass: ConsoleLogger, useClass: ConsoleLogger,
}, },
{
provide: TEAM_STATS_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryTeamStatsRepository(logger),
inject: [LOGGER_TOKEN],
},
{ {
provide: MEDIA_REPOSITORY_TOKEN, provide: MEDIA_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryMediaRepository(logger), useFactory: (logger: Logger) => new InMemoryMediaRepository(logger),
@@ -47,7 +39,6 @@ export const TeamProviders: Provider[] = [
}, },
{ {
provide: AllTeamsPresenter, provide: AllTeamsPresenter,
useFactory: (teamStatsRepository: ITeamStatsRepository) => new AllTeamsPresenter(teamStatsRepository), useFactory: () => new AllTeamsPresenter(),
inject: [TEAM_STATS_REPOSITORY_TOKEN],
}, },
]; ];

View File

@@ -113,7 +113,6 @@ describe('TeamService', () => {
const teamStatsRepository = { const teamStatsRepository = {
getTeamStats: vi.fn(), getTeamStats: vi.fn(),
getTeamStatsSync: vi.fn(),
saveTeamStats: vi.fn(), saveTeamStats: vi.fn(),
getAllStats: vi.fn(), getAllStats: vi.fn(),
clear: vi.fn(), clear: vi.fn(),
@@ -126,6 +125,10 @@ describe('TeamService', () => {
saveDriverAvatar: vi.fn(), saveDriverAvatar: vi.fn(),
}; };
const resultRepository = {
findAll: vi.fn(),
};
const allTeamsPresenter = { const allTeamsPresenter = {
reset: vi.fn(), reset: vi.fn(),
present: vi.fn(), present: vi.fn(),
@@ -140,6 +143,7 @@ describe('TeamService', () => {
logger, logger,
teamStatsRepository as unknown as never, teamStatsRepository as unknown as never,
mediaRepository as unknown as never, mediaRepository as unknown as never,
resultRepository as unknown as never,
allTeamsPresenter as unknown as never allTeamsPresenter as unknown as never
); );
}); });
@@ -558,4 +562,4 @@ describe('TeamService', () => {
executeSpy.mockRestore(); executeSpy.mockRestore();
}); });
}); });

View File

@@ -37,9 +37,10 @@ import { CreateTeamPresenter } from './presenters/CreateTeamPresenter';
import { UpdateTeamPresenter } from './presenters/UpdateTeamPresenter'; import { UpdateTeamPresenter } from './presenters/UpdateTeamPresenter';
// Tokens // Tokens
import { TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN, TEAM_STATS_REPOSITORY_TOKEN, MEDIA_REPOSITORY_TOKEN } from './TeamTokens'; import { TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN, TEAM_STATS_REPOSITORY_TOKEN, MEDIA_REPOSITORY_TOKEN, RESULT_REPOSITORY_TOKEN } from './TeamTokens';
import type { ITeamStatsRepository } from '@core/racing/domain/repositories/ITeamStatsRepository'; import type { ITeamStatsRepository } from '@core/racing/domain/repositories/ITeamStatsRepository';
import type { IMediaRepository } from '@core/racing/domain/repositories/IMediaRepository'; import type { IMediaRepository } from '@core/racing/domain/repositories/IMediaRepository';
import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository';
@Injectable() @Injectable()
export class TeamService { export class TeamService {
@@ -50,6 +51,7 @@ export class TeamService {
@Inject(LOGGER_TOKEN) private readonly logger: Logger, @Inject(LOGGER_TOKEN) private readonly logger: Logger,
@Inject(TEAM_STATS_REPOSITORY_TOKEN) private readonly teamStatsRepository: ITeamStatsRepository, @Inject(TEAM_STATS_REPOSITORY_TOKEN) private readonly teamStatsRepository: ITeamStatsRepository,
@Inject(MEDIA_REPOSITORY_TOKEN) private readonly mediaRepository: IMediaRepository, @Inject(MEDIA_REPOSITORY_TOKEN) private readonly mediaRepository: IMediaRepository,
@Inject(RESULT_REPOSITORY_TOKEN) private readonly resultRepository: IResultRepository,
private readonly allTeamsPresenter: AllTeamsPresenter, private readonly allTeamsPresenter: AllTeamsPresenter,
) {} ) {}
@@ -61,6 +63,7 @@ export class TeamService {
this.membershipRepository, this.membershipRepository,
this.teamStatsRepository, this.teamStatsRepository,
this.mediaRepository, this.mediaRepository,
this.resultRepository,
this.logger, this.logger,
this.allTeamsPresenter this.allTeamsPresenter
); );
@@ -174,13 +177,13 @@ export class TeamService {
} }
async getDriverTeam(driverId: string): Promise<GetDriverTeamOutputDTO | null> { async getDriverTeam(driverId: string): Promise<GetDriverTeamOutputDTO | null> {
this.logger.debug(`[TeamService] Fetching driver team for driverId: ${driverId}`); this.logger.debug(`[TeamService] Fetching team for driverId: ${driverId}`);
const presenter = new DriverTeamPresenter(); const presenter = new DriverTeamPresenter();
const useCase = new GetDriverTeamUseCase(this.teamRepository, this.membershipRepository, this.logger, presenter); const useCase = new GetDriverTeamUseCase(this.teamRepository, this.membershipRepository, this.logger, presenter);
const result = await useCase.execute({ driverId }); const result = await useCase.execute({ driverId });
if (result.isErr()) { if (result.isErr()) {
this.logger.error(`Error fetching driver team for driverId: ${driverId}: ${result.error?.details?.message || 'Unknown error'}`); this.logger.error(`Error fetching team for driverId: ${driverId}: ${result.error?.details?.message || 'Unknown error'}`);
return null; return null;
} }

View File

@@ -4,4 +4,5 @@ export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
export const IMAGE_SERVICE_TOKEN = 'IImageServicePort'; export const IMAGE_SERVICE_TOKEN = 'IImageServicePort';
export const LOGGER_TOKEN = 'Logger'; export const LOGGER_TOKEN = 'Logger';
export const TEAM_STATS_REPOSITORY_TOKEN = 'ITeamStatsRepository'; export const TEAM_STATS_REPOSITORY_TOKEN = 'ITeamStatsRepository';
export const MEDIA_REPOSITORY_TOKEN = 'IMediaRepository'; export const MEDIA_REPOSITORY_TOKEN = 'IMediaRepository';
export const RESULT_REPOSITORY_TOKEN = 'IResultRepository';

View File

@@ -326,6 +326,15 @@ export class TeamDTO {
@IsArray() @IsArray()
leagues!: string[]; leagues!: string[];
@ApiProperty({ required: false })
@IsOptional()
@IsString()
category?: string | undefined;
@ApiProperty()
@IsBoolean()
isRecruiting!: boolean;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@IsString() @IsString()

View File

@@ -37,10 +37,16 @@ export class TeamListItemDTO {
@ApiProperty({ required: false, enum: ['beginner', 'intermediate', 'advanced', 'pro'] }) @ApiProperty({ required: false, enum: ['beginner', 'intermediate', 'advanced', 'pro'] })
performanceLevel?: 'beginner' | 'intermediate' | 'advanced' | 'pro'; performanceLevel?: 'beginner' | 'intermediate' | 'advanced' | 'pro';
@ApiProperty({ required: false })
category?: string | undefined;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
logoUrl?: string; logoUrl?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
rating?: number; rating?: number;
@ApiProperty()
isRecruiting!: boolean;
} }

View File

@@ -1,53 +1,40 @@
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { GetAllTeamsResult } from '@core/racing/application/use-cases/GetAllTeamsUseCase'; import type { GetAllTeamsResult } from '@core/racing/application/use-cases/GetAllTeamsUseCase';
import { GetAllTeamsOutputDTO } from '../dtos/GetAllTeamsOutputDTO'; import { GetAllTeamsOutputDTO } from '../dtos/GetAllTeamsOutputDTO';
import type { ITeamStatsRepository } from '@core/racing/domain/repositories/ITeamStatsRepository'; import { TeamListItemDTO } from '../dtos/TeamListItemDTO';
export class AllTeamsPresenter implements UseCaseOutputPort<GetAllTeamsResult> { export class AllTeamsPresenter implements UseCaseOutputPort<GetAllTeamsResult> {
private model: GetAllTeamsOutputDTO | null = null; private model: GetAllTeamsOutputDTO | null = null;
constructor(
private readonly teamStatsRepository: ITeamStatsRepository
) {}
reset(): void { reset(): void {
this.model = null; this.model = null;
} }
present(result: GetAllTeamsResult): void { present(result: GetAllTeamsResult): void {
const teams: TeamListItemDTO[] = result.teams.map(team => {
const dto = new TeamListItemDTO();
dto.id = team.id;
dto.name = team.name;
dto.tag = team.tag;
dto.description = team.description || '';
dto.memberCount = team.memberCount;
dto.leagues = team.leagues || [];
dto.totalWins = team.totalWins ?? 0;
dto.totalRaces = team.totalRaces ?? 0;
dto.performanceLevel = (team.performanceLevel as 'beginner' | 'intermediate' | 'advanced' | 'pro') ?? 'intermediate';
dto.specialization = (team.specialization as 'endurance' | 'sprint' | 'mixed') ?? 'mixed';
dto.region = team.region ?? '';
dto.languages = team.languages ?? [];
// Return relative URL for proxying through Next.js rewrites
dto.logoUrl = `/api/media/teams/${team.id}/logo`;
dto.rating = team.rating ?? 0;
dto.category = team.category;
dto.isRecruiting = team.isRecruiting;
return dto;
});
this.model = { this.model = {
teams: result.teams.map(team => { teams,
const stats = this.teamStatsRepository.getTeamStatsSync(team.id.toString());
return {
id: team.id,
name: team.name.toString(),
tag: team.tag.toString(),
description: team.description?.toString() || '',
memberCount: team.memberCount,
leagues: team.leagues?.map(l => l.toString()) || [],
// Add stats fields
...(stats ? {
totalWins: stats.totalWins,
totalRaces: stats.totalRaces,
performanceLevel: stats.performanceLevel,
specialization: stats.specialization,
region: stats.region,
languages: stats.languages,
logoUrl: stats.logoUrl,
rating: stats.rating,
} : {
totalWins: 0,
totalRaces: 0,
performanceLevel: 'beginner',
specialization: 'mixed',
region: '',
languages: [],
logoUrl: '',
rating: 0,
}),
};
}),
totalCount: result.totalCount ?? result.teams.length, totalCount: result.totalCount ?? result.teams.length,
}; };
} }

View File

@@ -21,6 +21,7 @@ export class DriverTeamPresenter implements UseCaseOutputPort<GetDriverTeamResul
description: result.team.description?.toString() || '', description: result.team.description?.toString() || '',
ownerId: result.team.ownerId.toString(), ownerId: result.team.ownerId.toString(),
leagues: result.team.leagues?.map(l => l.toString()) || [], leagues: result.team.leagues?.map(l => l.toString()) || [],
isRecruiting: result.team.isRecruiting,
createdAt: result.team.createdAt.toDate().toISOString(), createdAt: result.team.createdAt.toDate().toISOString(),
}, },
membership: { membership: {

View File

@@ -18,6 +18,8 @@ export class TeamDetailsPresenter implements UseCaseOutputPort<GetTeamDetailsRes
description: result.team.description?.toString() || '', description: result.team.description?.toString() || '',
ownerId: result.team.ownerId.toString(), ownerId: result.team.ownerId.toString(),
leagues: result.team.leagues?.map(l => l.toString()) || [], leagues: result.team.leagues?.map(l => l.toString()) || [],
category: result.team.category,
isRecruiting: result.team.isRecruiting,
createdAt: result.team.createdAt.toDate().toISOString(), createdAt: result.team.createdAt.toDate().toISOString(),
}, },
membership: result.membership membership: result.membership

View File

@@ -55,6 +55,7 @@ import {
TeamMembershipOrmEntity, TeamMembershipOrmEntity,
TeamOrmEntity, TeamOrmEntity,
} from '@adapters/racing/persistence/typeorm/entities/TeamOrmEntities'; } from '@adapters/racing/persistence/typeorm/entities/TeamOrmEntities';
import { TeamStatsOrmEntity } from '@adapters/racing/persistence/typeorm/entities/TeamStatsOrmEntity';
import { TypeOrmDriverRepository } from '@adapters/racing/persistence/typeorm/repositories/TypeOrmDriverRepository'; import { TypeOrmDriverRepository } from '@adapters/racing/persistence/typeorm/repositories/TypeOrmDriverRepository';
import { TypeOrmLeagueMembershipRepository } from '@adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueMembershipRepository'; import { TypeOrmLeagueMembershipRepository } from '@adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueMembershipRepository';
@@ -79,9 +80,11 @@ import { TypeOrmTeamMembershipRepository, TypeOrmTeamRepository } from '@adapter
// Import in-memory implementations for new repositories (TypeORM versions not yet implemented) // Import in-memory implementations for new repositories (TypeORM versions not yet implemented)
import { InMemoryDriverStatsRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverStatsRepository'; import { InMemoryDriverStatsRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverStatsRepository';
import { InMemoryTeamStatsRepository } from '@adapters/racing/persistence/inmemory/InMemoryTeamStatsRepository';
import { InMemoryMediaRepository } from '@adapters/racing/persistence/media/InMemoryMediaRepository'; import { InMemoryMediaRepository } from '@adapters/racing/persistence/media/InMemoryMediaRepository';
// Import TypeORM repository for team stats
import { TypeOrmTeamStatsRepository } from '@adapters/racing/persistence/typeorm/repositories/TypeOrmTeamStatsRepository';
import { DriverOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/DriverOrmMapper'; import { DriverOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/DriverOrmMapper';
import { LeagueMembershipOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/LeagueMembershipOrmMapper'; import { LeagueMembershipOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/LeagueMembershipOrmMapper';
import { LeagueOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper'; import { LeagueOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper';
@@ -105,6 +108,7 @@ import {
import { MoneyOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/MoneyOrmMapper'; import { MoneyOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/MoneyOrmMapper';
import { PenaltyOrmMapper, ProtestOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/StewardingOrmMappers'; import { PenaltyOrmMapper, ProtestOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/StewardingOrmMappers';
import { TeamMembershipOrmMapper, TeamOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/TeamOrmMappers'; import { TeamMembershipOrmMapper, TeamOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/TeamOrmMappers';
import { TeamStatsOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/TeamStatsOrmMapper';
import { getPointsSystems } from '@adapters/bootstrap/PointsSystems'; import { getPointsSystems } from '@adapters/bootstrap/PointsSystems';
import type { Logger } from '@core/shared/application/Logger'; import type { Logger } from '@core/shared/application/Logger';
@@ -126,6 +130,7 @@ const typeOrmFeatureImports = [
TeamOrmEntity, TeamOrmEntity,
TeamMembershipOrmEntity, TeamMembershipOrmEntity,
TeamJoinRequestOrmEntity, TeamJoinRequestOrmEntity,
TeamStatsOrmEntity,
PenaltyOrmEntity, PenaltyOrmEntity,
ProtestOrmEntity, ProtestOrmEntity,
@@ -155,6 +160,7 @@ const typeOrmFeatureImports = [
{ provide: TeamOrmMapper, useFactory: () => new TeamOrmMapper() }, { provide: TeamOrmMapper, useFactory: () => new TeamOrmMapper() },
{ provide: TeamMembershipOrmMapper, useFactory: () => new TeamMembershipOrmMapper() }, { provide: TeamMembershipOrmMapper, useFactory: () => new TeamMembershipOrmMapper() },
{ provide: TeamStatsOrmMapper, useFactory: () => new TeamStatsOrmMapper() },
{ provide: PenaltyOrmMapper, useFactory: () => new PenaltyOrmMapper() }, { provide: PenaltyOrmMapper, useFactory: () => new PenaltyOrmMapper() },
{ provide: ProtestOrmMapper, useFactory: () => new ProtestOrmMapper() }, { provide: ProtestOrmMapper, useFactory: () => new ProtestOrmMapper() },
@@ -321,8 +327,8 @@ const typeOrmFeatureImports = [
}, },
{ {
provide: TEAM_STATS_REPOSITORY_TOKEN, provide: TEAM_STATS_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryTeamStatsRepository(logger), useFactory: (repo: Repository<TeamStatsOrmEntity>, mapper: TeamStatsOrmMapper) => new TypeOrmTeamStatsRepository(repo, mapper),
inject: ['Logger'], inject: [getRepositoryToken(TeamStatsOrmEntity), TeamStatsOrmMapper],
}, },
{ {
provide: MEDIA_REPOSITORY_TOKEN, provide: MEDIA_REPOSITORY_TOKEN,
@@ -356,4 +362,4 @@ const typeOrmFeatureImports = [
MEDIA_REPOSITORY_TOKEN, MEDIA_REPOSITORY_TOKEN,
], ],
}) })
export class PostgresRacingPersistenceModule {} export class PostgresRacingPersistenceModule {}

View File

@@ -52,6 +52,25 @@ const SKILL_LEVELS: {
{ id: 'beginner', label: 'Beginner', icon: Shield, color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30', description: 'Learning the ropes' }, { id: 'beginner', label: 'Beginner', icon: Shield, color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30', description: 'Learning the ropes' },
]; ];
// ============================================================================
// CATEGORY CONFIG
// ============================================================================
const CATEGORIES: {
id: string;
label: string;
color: string;
bgColor: string;
borderColor: string;
}[] = [
{ id: 'beginner', label: 'Beginner', color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30' },
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', borderColor: 'border-primary-blue/30' },
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400', bgColor: 'bg-purple-400/10', borderColor: 'border-purple-400/30' },
{ id: 'pro', label: 'Pro', color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30' },
{ id: 'endurance', label: 'Endurance', color: 'text-orange-400', bgColor: 'bg-orange-400/10', borderColor: 'border-orange-400/30' },
{ id: 'sprint', label: 'Sprint', color: 'text-red-400', bgColor: 'bg-red-400/10', borderColor: 'border-red-400/30' },
];
// ============================================================================ // ============================================================================
// FEATURED DRIVER CARD COMPONENT // FEATURED DRIVER CARD COMPONENT
// ============================================================================ // ============================================================================
@@ -64,6 +83,7 @@ interface FeaturedDriverCardProps {
function FeaturedDriverCard({ driver, position, onClick }: FeaturedDriverCardProps) { function FeaturedDriverCard({ driver, position, onClick }: FeaturedDriverCardProps) {
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel); const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
const categoryConfig = CATEGORIES.find((c) => c.id === driver.category);
const getBorderColor = (pos: number) => { const getBorderColor = (pos: number) => {
switch (pos) { switch (pos) {
@@ -98,9 +118,16 @@ function FeaturedDriverCard({ driver, position, onClick }: FeaturedDriverCardPro
<span className="text-lg font-bold text-gray-400">#{position}</span> <span className="text-lg font-bold text-gray-400">#{position}</span>
)} )}
</div> </div>
<span className={`px-2 py-1 rounded-full text-[10px] font-medium ${levelConfig?.bgColor} ${levelConfig?.color} border ${levelConfig?.borderColor}`}> <div className="flex gap-2">
{levelConfig?.label} {categoryConfig && (
</span> <span className={`px-2 py-1 rounded-full text-[10px] font-medium ${categoryConfig.bgColor} ${categoryConfig.color} border ${categoryConfig.borderColor}`}>
{categoryConfig.label}
</span>
)}
<span className={`px-2 py-1 rounded-full text-[10px] font-medium ${levelConfig?.bgColor} ${levelConfig?.color} border ${levelConfig?.borderColor}`}>
{levelConfig?.label}
</span>
</div>
</div> </div>
{/* Avatar & Name */} {/* Avatar & Name */}
@@ -150,8 +177,8 @@ function SkillDistribution({ drivers }: SkillDistributionProps) {
const distribution = SKILL_LEVELS.map((level) => ({ const distribution = SKILL_LEVELS.map((level) => ({
...level, ...level,
count: drivers.filter((d) => d.skillLevel === level.id).length, count: drivers.filter((d) => d.skillLevel === level.id).length,
percentage: drivers.length > 0 percentage: drivers.length > 0
? Math.round((drivers.filter((d) => d.skillLevel === level.id).length / drivers.length) * 100) ? Math.round((drivers.filter((d) => d.skillLevel === level.id).length / drivers.length) * 100)
: 0, : 0,
})); }));
@@ -200,6 +227,66 @@ function SkillDistribution({ drivers }: SkillDistributionProps) {
); );
} }
// ============================================================================
// CATEGORY DISTRIBUTION COMPONENT
// ============================================================================
interface CategoryDistributionProps {
drivers: DriverLeaderboardItemViewModel[];
}
function CategoryDistribution({ drivers }: CategoryDistributionProps) {
const distribution = CATEGORIES.map((category) => ({
...category,
count: drivers.filter((d) => d.category === category.id).length,
percentage: drivers.length > 0
? Math.round((drivers.filter((d) => d.category === category.id).length / drivers.length) * 100)
: 0,
}));
return (
<div className="mb-10">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-purple-400/10 border border-purple-400/20">
<BarChart3 className="w-5 h-5 text-purple-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Category Distribution</h2>
<p className="text-xs text-gray-500">Driver population by category</p>
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
{distribution.map((category) => (
<div
key={category.id}
className={`p-4 rounded-xl ${category.bgColor} border ${category.borderColor}`}
>
<div className="flex items-center justify-between mb-3">
<span className={`text-2xl font-bold ${category.color}`}>{category.count}</span>
</div>
<p className="text-white font-medium mb-1">{category.label}</p>
<div className="w-full h-2 rounded-full bg-deep-graphite/50 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
category.id === 'beginner' ? 'bg-green-400' :
category.id === 'intermediate' ? 'bg-primary-blue' :
category.id === 'advanced' ? 'bg-purple-400' :
category.id === 'pro' ? 'bg-yellow-400' :
category.id === 'endurance' ? 'bg-orange-400' :
'bg-red-400'
}`}
style={{ width: `${category.percentage}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-1">{category.percentage}% of drivers</p>
</div>
))}
</div>
</div>
);
}
// ============================================================================ // ============================================================================
// LEADERBOARD PREVIEW COMPONENT // LEADERBOARD PREVIEW COMPONENT
// ============================================================================ // ============================================================================
@@ -258,6 +345,7 @@ function LeaderboardPreview({ drivers, onDriverClick }: LeaderboardPreviewProps)
<div className="divide-y divide-charcoal-outline/50"> <div className="divide-y divide-charcoal-outline/50">
{top5.map((driver, index) => { {top5.map((driver, index) => {
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel); const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
const categoryConfig = CATEGORIES.find((c) => c.id === driver.category);
const position = index + 1; const position = index + 1;
return ( return (
@@ -285,6 +373,9 @@ function LeaderboardPreview({ drivers, onDriverClick }: LeaderboardPreviewProps)
<div className="flex items-center gap-2 text-xs text-gray-500"> <div className="flex items-center gap-2 text-xs text-gray-500">
<Flag className="w-3 h-3" /> <Flag className="w-3 h-3" />
{driver.nationality} {driver.nationality}
{categoryConfig && (
<span className={categoryConfig.color}>{categoryConfig.label}</span>
)}
<span className={levelConfig?.color}>{levelConfig?.label}</span> <span className={levelConfig?.color}>{levelConfig?.label}</span>
</div> </div>
</div> </div>
@@ -336,6 +427,7 @@ function RecentActivity({ drivers, onDriverClick }: RecentActivityProps) {
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
{activeDrivers.map((driver) => { {activeDrivers.map((driver) => {
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel); const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
const categoryConfig = CATEGORIES.find((c) => c.id === driver.category);
return ( return (
<button <button
key={driver.id} key={driver.id}
@@ -350,7 +442,12 @@ function RecentActivity({ drivers, onDriverClick }: RecentActivityProps) {
<p className="text-sm font-medium text-white truncate group-hover:text-performance-green transition-colors"> <p className="text-sm font-medium text-white truncate group-hover:text-performance-green transition-colors">
{driver.name} {driver.name}
</p> </p>
<p className={`text-xs ${levelConfig?.color}`}>{levelConfig?.label}</p> <div className="flex items-center justify-center gap-1 text-xs">
{categoryConfig && (
<span className={categoryConfig.color}>{categoryConfig.label}</span>
)}
<span className={levelConfig?.color}>{levelConfig?.label}</span>
</div>
</button> </button>
); );
})} })}
@@ -516,6 +613,9 @@ export default function DriversPage() {
{/* Skill Distribution */} {/* Skill Distribution */}
{!searchQuery && <SkillDistribution drivers={drivers} />} {!searchQuery && <SkillDistribution drivers={drivers} />}
{/* Category Distribution */}
{!searchQuery && <CategoryDistribution drivers={drivers} />}
{/* Leaderboard Preview */} {/* Leaderboard Preview */}
<LeaderboardPreview drivers={filteredDrivers} onDriverClick={handleDriverClick} /> <LeaderboardPreview drivers={filteredDrivers} onDriverClick={handleDriverClick} />

View File

@@ -422,7 +422,15 @@ export default function LeaguesPage() {
// Group leagues by category for slider view // Group leagues by category for slider view
const leaguesByCategory = CATEGORIES.reduce( const leaguesByCategory = CATEGORIES.reduce(
(acc, category) => { (acc, category) => {
acc[category.id] = searchFilteredLeagues.filter(category.filter); // First try to use the dedicated category field, fall back to scoring-based filtering
acc[category.id] = searchFilteredLeagues.filter((league) => {
// If league has a category field, use it directly
if (league.category) {
return league.category === category.id;
}
// Otherwise fall back to the existing scoring-based filter
return category.filter(league);
});
return acc; return acc;
}, },
{} as Record<CategoryId, LeagueSummaryViewModel[]>, {} as Record<CategoryId, LeagueSummaryViewModel[]>,

View File

@@ -213,6 +213,12 @@ export default function TeamDetailPage() {
<div className="flex items-center gap-4 text-sm text-gray-400"> <div className="flex items-center gap-4 text-sm text-gray-400">
<span>{memberships.length} {memberships.length === 1 ? 'member' : 'members'}</span> <span>{memberships.length} {memberships.length === 1 ? 'member' : 'members'}</span>
{team.category && (
<span className="flex items-center gap-1 text-purple-400">
<span className="w-2 h-2 rounded-full bg-purple-400"></span>
{team.category}
</span>
)}
{team.createdAt && ( {team.createdAt && (
<span> <span>
Founded {new Date(team.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} Founded {new Date(team.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
@@ -267,6 +273,9 @@ export default function TeamDetailPage() {
<h3 className="text-xl font-semibold text-white mb-4">Quick Stats</h3> <h3 className="text-xl font-semibold text-white mb-4">Quick Stats</h3>
<div className="space-y-3"> <div className="space-y-3">
<StatItem label="Members" value={memberships.length.toString()} color="text-primary-blue" /> <StatItem label="Members" value={memberships.length.toString()} color="text-primary-blue" />
{team.category && (
<StatItem label="Category" value={team.category} color="text-purple-400" />
)}
{leagueCount > 0 && ( {leagueCount > 0 && (
<StatItem label="Leagues" value={leagueCount.toString()} color="text-green-400" /> <StatItem label="Leagues" value={leagueCount.toString()} color="text-green-400" />
)} )}

View File

@@ -416,6 +416,12 @@ export default function TeamLeaderboardPage() {
</p> </p>
<div className="flex items-center gap-2 text-xs text-gray-500 flex-wrap"> <div className="flex items-center gap-2 text-xs text-gray-500 flex-wrap">
<span className={`${levelConfig?.color}`}>{levelConfig?.label}</span> <span className={`${levelConfig?.color}`}>{levelConfig?.label}</span>
{team.category && (
<span className="flex items-center gap-1 text-purple-400">
<span className="w-1.5 h-1.5 rounded-full bg-purple-400"></span>
{team.category}
</span>
)}
{team.region && ( {team.region && (
<span className="flex items-center gap-1 text-gray-400"> <span className="flex items-center gap-1 text-gray-400">
<Globe className="w-3 h-3 text-neon-aqua" /> <Globe className="w-3 h-3 text-neon-aqua" />
@@ -497,4 +503,4 @@ export default function TeamLeaderboardPage() {
</div> </div>
</div> </div>
); );
} }

View File

@@ -92,9 +92,17 @@ export default function TeamLeaderboardPreview({ teams, onTeamClick }: TeamLeade
<p className="text-white font-medium truncate group-hover:text-purple-400 transition-colors"> <p className="text-white font-medium truncate group-hover:text-purple-400 transition-colors">
{team.name} {team.name}
</p> </p>
<div className="flex items-center gap-2 text-xs text-gray-500"> <div className="flex items-center gap-2 text-xs text-gray-500 flex-wrap">
<Users className="w-3 h-3" /> {team.category && (
{team.memberCount} members <span className="flex items-center gap-1 text-purple-400">
<span className="w-1.5 h-1.5 rounded-full bg-purple-400"></span>
{team.category}
</span>
)}
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
{team.memberCount} members
</span>
<span className={levelConfig?.color}>{levelConfig?.label}</span> <span className={levelConfig?.color}>{levelConfig?.label}</span>
</div> </div>
</div> </div>

View File

@@ -51,6 +51,48 @@ function getChampionshipLabel(type?: string) {
} }
} }
function getCategoryLabel(category?: string): string {
if (!category) return '';
switch (category) {
case 'driver':
return 'Driver';
case 'team':
return 'Team';
case 'nations':
return 'Nations';
case 'trophy':
return 'Trophy';
case 'endurance':
return 'Endurance';
case 'sprint':
return 'Sprint';
default:
return category.charAt(0).toUpperCase() + category.slice(1);
}
}
function getCategoryColor(category?: string): string {
if (!category) return 'bg-gray-500/20 text-gray-400 border-gray-500/30';
switch (category) {
case 'driver':
return 'bg-purple-500/20 text-purple-400 border-purple-500/30';
case 'team':
return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
case 'nations':
return 'bg-green-500/20 text-green-400 border-green-500/30';
case 'trophy':
return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30';
case 'endurance':
return 'bg-orange-500/20 text-orange-400 border-orange-500/30';
case 'sprint':
return 'bg-red-500/20 text-red-400 border-red-500/30';
default:
return 'bg-gray-500/20 text-gray-400 border-gray-500/30';
}
}
function getGameColor(gameId?: string): string { function getGameColor(gameId?: string): string {
switch (gameId) { switch (gameId) {
case 'iracing': case 'iracing':
@@ -81,6 +123,8 @@ export default function LeagueCard({ league, onClick }: LeagueCardProps) {
const gameColorClass = getGameColor(league.scoring?.gameId); const gameColorClass = getGameColor(league.scoring?.gameId);
const isNew = isNewLeague(league.createdAt); const isNew = isNewLeague(league.createdAt);
const isTeamLeague = league.maxTeams && league.maxTeams > 0; const isTeamLeague = league.maxTeams && league.maxTeams > 0;
const categoryLabel = getCategoryLabel(league.category);
const categoryColorClass = getCategoryColor(league.category);
// Calculate fill percentage - use teams for team leagues, drivers otherwise // Calculate fill percentage - use teams for team leagues, drivers otherwise
const usedSlots = isTeamLeague ? (league.usedTeamSlots ?? 0) : (league.usedDriverSlots ?? 0); const usedSlots = isTeamLeague ? (league.usedTeamSlots ?? 0) : (league.usedDriverSlots ?? 0);
@@ -128,6 +172,11 @@ export default function LeagueCard({ league, onClick }: LeagueCardProps) {
{league.scoring.gameName} {league.scoring.gameName}
</span> </span>
)} )}
{league.category && (
<span className={`px-2 py-0.5 rounded-full text-[10px] font-semibold border ${categoryColorClass}`}>
{categoryLabel}
</span>
)}
</div> </div>
{/* Championship Type Badge - Top Right */} {/* Championship Type Badge - Top Right */}

View File

@@ -91,7 +91,13 @@ export default function FeaturedRecruiting({ teams, onTeamClick }: FeaturedRecru
</h3> </h3>
<p className="text-xs text-gray-500 line-clamp-2 mb-3">{team.description}</p> <p className="text-xs text-gray-500 line-clamp-2 mb-3">{team.description}</p>
<div className="flex items-center gap-3 text-xs text-gray-400"> <div className="flex items-center gap-2 text-xs text-gray-400 flex-wrap">
{team.category && (
<span className="flex items-center gap-1 text-purple-400">
<span className="w-1.5 h-1.5 rounded-full bg-purple-400"></span>
{team.category}
</span>
)}
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Users className="w-3 h-3" /> <Users className="w-3 h-3" />
{team.memberCount} {team.memberCount}

View File

@@ -88,8 +88,9 @@ export default function SkillLevelSection({
id={team.id} id={team.id}
name={team.name} name={team.name}
description={team.description ?? ''} description={team.description ?? ''}
logo={team.logoUrl}
memberCount={team.memberCount} memberCount={team.memberCount}
rating={null} rating={team.rating}
totalWins={team.totalWins} totalWins={team.totalWins}
totalRaces={team.totalRaces} totalRaces={team.totalRaces}
performanceLevel={team.performanceLevel as SkillLevel} performanceLevel={team.performanceLevel as SkillLevel}
@@ -97,10 +98,11 @@ export default function SkillLevelSection({
specialization={specialization(team.specialization)} specialization={specialization(team.specialization)}
region={team.region ?? ''} region={team.region ?? ''}
languages={team.languages} languages={team.languages}
category={team.category}
onClick={() => onTeamClick(team.id)} onClick={() => onTeamClick(team.id)}
/> />
))} ))}
</div> </div>
</div> </div>
); );
} }

View File

@@ -34,6 +34,7 @@ interface TeamCardProps {
region?: string; region?: string;
languages?: string[] | undefined; languages?: string[] | undefined;
leagues?: string[]; leagues?: string[];
category?: string;
onClick?: () => void; onClick?: () => void;
} }
@@ -77,6 +78,7 @@ export default function TeamCard({
specialization, specialization,
region, region,
languages, languages,
category,
onClick, onClick,
}: TeamCardProps) { }: TeamCardProps) {
const { mediaService } = useServices(); const { mediaService } = useServices();
@@ -119,21 +121,27 @@ export default function TeamCard({
)} )}
</div> </div>
{/* Performance Level */} {/* Performance Level & Category */}
{performanceBadge && ( <div className="mt-1.5 flex items-center gap-2 flex-wrap">
<div className="mt-1.5 flex items-center gap-2"> {performanceBadge && (
<span className={`flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border ${performanceBadge.bgColor}`}> <span className={`flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border ${performanceBadge.bgColor}`}>
<performanceBadge.icon className={`w-3 h-3 ${performanceBadge.color}`} /> <performanceBadge.icon className={`w-3 h-3 ${performanceBadge.color}`} />
<span className={performanceBadge.color}>{performanceBadge.label}</span> <span className={performanceBadge.color}>{performanceBadge.label}</span>
</span> </span>
{specializationBadge && ( )}
<span className="flex items-center gap-1 text-[10px] text-gray-500"> {specializationBadge && (
<specializationBadge.icon className={`w-3 h-3 ${specializationBadge.color}`} /> <span className="flex items-center gap-1 text-[10px] text-gray-500">
{specializationBadge.label} <specializationBadge.icon className={`w-3 h-3 ${specializationBadge.color}`} />
</span> {specializationBadge.label}
)} </span>
</div> )}
)} {category && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-purple-500/20 border border-purple-500/30 text-purple-400">
<span className="w-2 h-2 rounded-full bg-purple-400"></span>
{category}
</span>
)}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -140,7 +140,13 @@ export default function TeamLeaderboardPreview({
<p className="text-white font-medium truncate group-hover:text-purple-400 transition-colors"> <p className="text-white font-medium truncate group-hover:text-purple-400 transition-colors">
{team.name} {team.name}
</p> </p>
<div className="flex items-center gap-3 text-xs text-gray-500"> <div className="flex items-center gap-2 text-xs text-gray-500 flex-wrap">
{team.category && (
<span className="flex items-center gap-1 text-purple-400">
<span className="w-1.5 h-1.5 rounded-full bg-purple-400"></span>
{team.category}
</span>
)}
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Users className="w-3 h-3" /> <Users className="w-3 h-3" />
{team.memberCount} {team.memberCount}
@@ -161,7 +167,7 @@ export default function TeamLeaderboardPreview({
{/* Rating */} {/* Rating */}
<div className="text-right"> <div className="text-right">
<p className="text-purple-400 font-mono font-semibold"> <p className="text-purple-400 font-mono font-semibold">
{'—'} {typeof team.rating === 'number' ? Math.round(team.rating).toLocaleString() : '—'}
</p> </p>
<p className="text-xs text-gray-500">Rating</p> <p className="text-xs text-gray-500">Rating</p>
</div> </div>
@@ -172,4 +178,4 @@ export default function TeamLeaderboardPreview({
</div> </div>
</div> </div>
); );
} }

View File

@@ -140,6 +140,13 @@ export default function TopThreePodium({ teams, onClick }: TopThreePodiumProps)
{team.name} {team.name}
</p> </p>
{/* Category */}
{team.category && (
<p className="text-xs text-purple-400 text-center mt-1">
{team.category}
</p>
)}
{/* Rating */} {/* Rating */}
<p className={`text-lg md:text-xl font-mono font-bold ${getPositionColor(position)} text-center`}> <p className={`text-lg md:text-xl font-mono font-bold ${getPositionColor(position)} text-center`}>
{'—'} {'—'}
@@ -172,4 +179,4 @@ export default function TopThreePodium({ teams, onClick }: TopThreePodiumProps)
</div> </div>
</div> </div>
); );
} }

View File

@@ -121,6 +121,7 @@ export class LeagueService {
structureSummary: league.scoring?.scoringPresetName ?? 'Custom rules', structureSummary: league.scoring?.scoringPresetName ?? 'Custom rules',
scoringPatternSummary: league.scoring?.scoringPatternSummary, scoringPatternSummary: league.scoring?.scoringPatternSummary,
timingSummary: league.timingSummary ?? '', timingSummary: league.timingSummary ?? '',
...(league.category ? { category: league.category } : {}),
...(league.scoring ? { scoring: league.scoring } : {}), ...(league.scoring ? { scoring: league.scoring } : {}),
})); }));
} }

View File

@@ -2,6 +2,7 @@ import { DeleteMediaViewModel } from '@/lib/view-models/DeleteMediaViewModel';
import { MediaViewModel } from '@/lib/view-models/MediaViewModel'; import { MediaViewModel } from '@/lib/view-models/MediaViewModel';
import { UploadMediaViewModel } from '@/lib/view-models/UploadMediaViewModel'; import { UploadMediaViewModel } from '@/lib/view-models/UploadMediaViewModel';
import type { MediaApiClient } from '../../api/media/MediaApiClient'; import type { MediaApiClient } from '../../api/media/MediaApiClient';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
// Local request shape mirroring the media upload API contract until a generated type is available // Local request shape mirroring the media upload API contract until a generated type is available
type UploadMediaRequest = { file: File; type: string; category?: string }; type UploadMediaRequest = { file: File; type: string; category?: string };
@@ -43,6 +44,7 @@ export class MediaService {
/** /**
* Get team logo URL * Get team logo URL
* Returns relative URL for proxying through Next.js rewrites
*/ */
getTeamLogo(teamId: string): string { getTeamLogo(teamId: string): string {
return `/api/media/teams/${teamId}/logo`; return `/api/media/teams/${teamId}/logo`;
@@ -50,6 +52,7 @@ export class MediaService {
/** /**
* Get driver avatar URL * Get driver avatar URL
* Returns relative URL for proxying through Next.js rewrites
*/ */
getDriverAvatar(driverId: string): string { getDriverAvatar(driverId: string): string {
return `/api/media/avatar/${driverId}`; return `/api/media/avatar/${driverId}`;
@@ -57,6 +60,7 @@ export class MediaService {
/** /**
* Get league cover URL * Get league cover URL
* Returns relative URL for proxying through Next.js rewrites
*/ */
getLeagueCover(leagueId: string): string { getLeagueCover(leagueId: string): string {
return `/api/media/leagues/${leagueId}/cover`; return `/api/media/leagues/${leagueId}/cover`;
@@ -64,6 +68,7 @@ export class MediaService {
/** /**
* Get league logo URL * Get league logo URL
* Returns relative URL for proxying through Next.js rewrites
*/ */
getLeagueLogo(leagueId: string): string { getLeagueLogo(leagueId: string): string {
return `/api/media/leagues/${leagueId}/logo`; return `/api/media/leagues/${leagueId}/logo`;

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */
@@ -10,6 +10,7 @@ export interface DashboardDriverSummaryDTO {
name: string; name: string;
country: string; country: string;
avatarUrl: string; avatarUrl: string;
category?: string;
rating?: number; rating?: number;
globalRank?: number; globalRank?: number;
totalRaces: number; totalRaces: number;

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */
@@ -12,4 +12,5 @@ export interface DriverDTO {
country: string; country: string;
bio?: string; bio?: string;
joinedAt: string; joinedAt: string;
category?: string;
} }

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */
@@ -10,6 +10,7 @@ export interface DriverLeaderboardItemDTO {
name: string; name: string;
rating: number; rating: number;
skillLevel: string; skillLevel: string;
category?: string;
nationality: string; nationality: string;
racesCompleted: number; racesCompleted: number;
wins: number; wins: number;

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */
@@ -12,6 +12,7 @@ export interface DriverProfileDriverSummaryDTO {
avatarUrl: string; avatarUrl: string;
iracingId?: string; iracingId?: string;
joinedAt: string; joinedAt: string;
category?: string;
rating?: number; rating?: number;
globalRank?: number; globalRank?: number;
consistency?: number; consistency?: number;

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Auto-generated DTO from OpenAPI spec * Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f * Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts * This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types * Do not edit manually - regenerate using: npm run api:generate-types
*/ */

Some files were not shown because too many files have changed in this diff Show More