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';
}
private getMediaBaseUrl(): string {
return process.env.NODE_ENV === 'development' ? 'http://localhost:3001' : 'https://api.gridpilot.io';
}
async execute(): Promise<void> {
const existingDrivers = await this.seedDeps.driverRepository.findAll();
const persistence = this.getApiPersistence();
@@ -428,7 +432,7 @@ export class SeedRacingData {
private calculateTeamStats(team: Team, results: Result[], drivers: Driver[]): TeamStats {
const wins = results.filter(r => r.position.toNumber() === 1).length;
const totalRaces = results.length;
// Calculate rating
const baseRating = 1000;
const winBonus = wins * 50;
@@ -462,7 +466,7 @@ export class SeedRacingData {
})));
return {
logoUrl: `https://api.gridpilot.io/media/team/${team.id}/logo.png`,
logoUrl: `${this.getMediaBaseUrl()}/api/media/teams/${team.id}/logo`,
performanceLevel,
specialization,
region,
@@ -482,21 +486,22 @@ export class SeedRacingData {
}
private async seedMediaAssets(seed: any): Promise<void> {
// Seed driver avatars
const baseUrl = this.getMediaBaseUrl();
// Seed driver avatars using static files
for (const driver of seed.drivers) {
const avatarUrl = `https://api.gridpilot.io/media/driver/${driver.id}/avatar.png`;
// Type assertion to access the helper method
const avatarUrl = this.getDriverAvatarUrl(driver.id);
const mediaRepo = this.seedDeps.mediaRepository as any;
if (mediaRepo.setDriverAvatar) {
mediaRepo.setDriverAvatar(driver.id, avatarUrl);
}
}
// Seed team logos
// Seed team logos using API routes
for (const team of seed.teams) {
const logoUrl = `https://api.gridpilot.io/media/team/${team.id}/logo.png`;
const logoUrl = `${baseUrl}/api/media/teams/${team.id}/logo`;
const mediaRepo = this.seedDeps.mediaRepository as any;
if (mediaRepo.setTeamLogo) {
mediaRepo.setTeamLogo(team.id, logoUrl);
@@ -505,8 +510,8 @@ export class SeedRacingData {
// Seed track images
for (const track of seed.tracks || []) {
const trackImageUrl = `https://api.gridpilot.io/media/track/${track.id}/image.png`;
const trackImageUrl = `${baseUrl}/api/media/tracks/${track.id}/image`;
const mediaRepo = this.seedDeps.mediaRepository as any;
if (mediaRepo.setTrackImage) {
mediaRepo.setTrackImage(track.id, trackImageUrl);
@@ -516,8 +521,8 @@ export class SeedRacingData {
// Seed category icons (if categories exist)
const categories = ['beginner', 'intermediate', 'advanced', 'pro', 'endurance', 'sprint'];
for (const category of categories) {
const iconUrl = `https://api.gridpilot.io/media/category/${category}/icon.png`;
const iconUrl = `${baseUrl}/api/media/categories/${category}/icon`;
const mediaRepo = this.seedDeps.mediaRepository as any;
if (mediaRepo.setCategoryIcon) {
mediaRepo.setCategoryIcon(category, iconUrl);
@@ -526,8 +531,8 @@ export class SeedRacingData {
// Seed sponsor logos
for (const sponsor of seed.sponsors || []) {
const logoUrl = `https://api.gridpilot.io/media/sponsor/${sponsor.id}/logo.png`;
const logoUrl = `${baseUrl}/api/media/sponsors/${sponsor.id}/logo`;
const mediaRepo = this.seedDeps.mediaRepository as any;
if (mediaRepo.setSponsorLogo) {
mediaRepo.setSponsorLogo(sponsor.id, logoUrl);
@@ -537,6 +542,48 @@ export class SeedRacingData {
this.logger.info(`[Bootstrap] Seeded media assets for ${seed.drivers.length} drivers, ${seed.teams.length} teams`);
}
/**
* Get deterministic avatar URL for a driver based on their ID
* Uses static files from the website public directory
*/
private getDriverAvatarUrl(driverId: string): string {
// Deterministic selection based on driver ID
const numericSuffixMatch = driverId.match(/(\d+)$/);
let useFemale = false;
let useNeutral = false;
if (numericSuffixMatch && numericSuffixMatch[1]) {
const numericSuffix = parseInt(numericSuffixMatch[1], 10);
// 40% female, 40% male, 20% neutral
if (numericSuffix % 5 === 0) {
useNeutral = true;
} else if (numericSuffix % 2 === 0) {
useFemale = true;
}
} else {
// Fallback hash
let hash = 0;
for (let i = 0; i < driverId.length; i++) {
hash = (hash * 31 + driverId.charCodeAt(i)) | 0;
}
const hashValue = Math.abs(hash);
if (hashValue % 5 === 0) {
useNeutral = true;
} else if (hashValue % 2 === 0) {
useFemale = true;
}
}
// Return static file paths that Next.js can serve
if (useNeutral) {
return '/images/avatars/neutral-default-avatar.jpeg';
} else if (useFemale) {
return '/images/avatars/female-default-avatar.jpeg';
} else {
return '/images/avatars/male-default-avatar.jpg';
}
}
private async clearExistingRacingData(): Promise<void> {
// Get all existing drivers
const drivers = await this.seedDeps.driverRepository.findAll();

View File

@@ -25,8 +25,51 @@ export class RacingDriverFactory {
private readonly persistence: 'postgres' | 'inmemory' = 'inmemory',
) {}
/**
* Get deterministic avatar URL for a driver based on their ID
* Uses static files from the website public directory
*/
getDriverAvatarUrl(driverId: string): string {
// Deterministic selection based on driver ID
const numericSuffixMatch = driverId.match(/(\d+)$/);
let useFemale = false;
let useNeutral = false;
if (numericSuffixMatch && numericSuffixMatch[1]) {
const numericSuffix = parseInt(numericSuffixMatch[1], 10);
// 40% female, 40% male, 20% neutral
if (numericSuffix % 5 === 0) {
useNeutral = true;
} else if (numericSuffix % 2 === 0) {
useFemale = true;
}
} else {
// Fallback hash
let hash = 0;
for (let i = 0; i < driverId.length; i++) {
hash = (hash * 31 + driverId.charCodeAt(i)) | 0;
}
const hashValue = Math.abs(hash);
if (hashValue % 5 === 0) {
useNeutral = true;
} else if (hashValue % 2 === 0) {
useFemale = true;
}
}
// Return static file paths that Next.js can serve
if (useNeutral) {
return '/images/avatars/neutral-default-avatar.jpeg';
} else if (useFemale) {
return '/images/avatars/female-default-avatar.jpeg';
} else {
return '/images/avatars/male-default-avatar.jpg';
}
}
create(): Driver[] {
const countries = ['DE', 'NL', 'FR', 'GB', 'US', 'CA', 'SE', 'NO', 'IT', 'ES', 'AU', 'BR', 'JP', 'KR', 'RU', 'PL', 'CZ', 'HU', 'AT', 'CH'] as const;
const categories = ['beginner', 'intermediate', 'advanced', 'pro', 'endurance', 'sprint'];
return Array.from({ length: this.driverCount }, (_, idx) => {
const i = idx + 1;
@@ -53,6 +96,9 @@ export class RacingDriverFactory {
joinedAt = faker.date.past({ years: 2, refDate: this.baseDate });
}
// Assign category - use all available categories
const category = faker.helpers.arrayElement(categories);
const driverData: {
id: string;
iracingId: string;
@@ -60,12 +106,14 @@ export class RacingDriverFactory {
country: string;
bio?: string;
joinedAt?: Date;
category?: string;
} = {
id: seedId(`driver-${i}`, this.persistence),
iracingId: String(100000 + i),
name: faker.person.fullName(),
country: faker.helpers.arrayElement(countries),
joinedAt,
category,
};
if (hasBio) {

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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": {
"type": "string"
},
"rating": {
"type": "number"
},
"experienceLevel": {
"type": "string"
},
"wins": {
"type": "number"
},
"podiums": {
"type": "number"
},
"totalRaces": {
"type": "number"
}
},
"required": [
@@ -6607,6 +6622,21 @@
"items": {
"type": "string"
}
},
"totalWins": {
"type": "number"
},
"totalRaces": {
"type": "number"
},
"performanceLevel": {
"type": "string"
},
"logoUrl": {
"type": "string"
},
"rating": {
"type": "number"
}
},
"required": [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -91,6 +91,70 @@ function buildLeagueCoverSvg(leagueId: string): string {
</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')
@Controller('media')
export class MediaController {
@@ -159,6 +223,132 @@ export class MediaController {
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()
@Get(':mediaId')
@ApiOperation({ summary: 'Get media by ID' })
@@ -237,4 +427,4 @@ export class MediaController {
res.status(HttpStatus.BAD_REQUEST).json(dto);
}
}
}
}

View File

@@ -58,7 +58,7 @@ describe('TeamController', () => {
it('should return team details', async () => {
const teamId = 'team-123';
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);
const mockReq = { user: { userId } } as any;
@@ -132,7 +132,7 @@ describe('TeamController', () => {
describe('getDriverTeam', () => {
it('should return driver team', async () => {
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);
const response = await controller.getDriverTeam(driverId);

View File

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

View File

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

View File

@@ -37,9 +37,10 @@ import { CreateTeamPresenter } from './presenters/CreateTeamPresenter';
import { UpdateTeamPresenter } from './presenters/UpdateTeamPresenter';
// 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 { IMediaRepository } from '@core/racing/domain/repositories/IMediaRepository';
import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository';
@Injectable()
export class TeamService {
@@ -50,6 +51,7 @@ export class TeamService {
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
@Inject(TEAM_STATS_REPOSITORY_TOKEN) private readonly teamStatsRepository: ITeamStatsRepository,
@Inject(MEDIA_REPOSITORY_TOKEN) private readonly mediaRepository: IMediaRepository,
@Inject(RESULT_REPOSITORY_TOKEN) private readonly resultRepository: IResultRepository,
private readonly allTeamsPresenter: AllTeamsPresenter,
) {}
@@ -61,6 +63,7 @@ export class TeamService {
this.membershipRepository,
this.teamStatsRepository,
this.mediaRepository,
this.resultRepository,
this.logger,
this.allTeamsPresenter
);
@@ -174,13 +177,13 @@ export class TeamService {
}
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 useCase = new GetDriverTeamUseCase(this.teamRepository, this.membershipRepository, this.logger, presenter);
const result = await useCase.execute({ driverId });
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;
}

View File

@@ -4,4 +4,5 @@ export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
export const IMAGE_SERVICE_TOKEN = 'IImageServicePort';
export const LOGGER_TOKEN = 'Logger';
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()
leagues!: string[];
@ApiProperty({ required: false })
@IsOptional()
@IsString()
category?: string | undefined;
@ApiProperty()
@IsBoolean()
isRecruiting!: boolean;
@ApiProperty({ required: false })
@IsOptional()
@IsString()

View File

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

View File

@@ -1,53 +1,40 @@
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { GetAllTeamsResult } from '@core/racing/application/use-cases/GetAllTeamsUseCase';
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> {
private model: GetAllTeamsOutputDTO | null = null;
constructor(
private readonly teamStatsRepository: ITeamStatsRepository
) {}
reset(): void {
this.model = null;
}
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 = {
teams: result.teams.map(team => {
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,
}),
};
}),
teams,
totalCount: result.totalCount ?? result.teams.length,
};
}

View File

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

View File

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

View File

@@ -55,6 +55,7 @@ import {
TeamMembershipOrmEntity,
TeamOrmEntity,
} 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 { 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 { InMemoryDriverStatsRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverStatsRepository';
import { InMemoryTeamStatsRepository } from '@adapters/racing/persistence/inmemory/InMemoryTeamStatsRepository';
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 { LeagueMembershipOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/LeagueMembershipOrmMapper';
import { LeagueOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper';
@@ -105,6 +108,7 @@ import {
import { MoneyOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/MoneyOrmMapper';
import { PenaltyOrmMapper, ProtestOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/StewardingOrmMappers';
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 type { Logger } from '@core/shared/application/Logger';
@@ -126,6 +130,7 @@ const typeOrmFeatureImports = [
TeamOrmEntity,
TeamMembershipOrmEntity,
TeamJoinRequestOrmEntity,
TeamStatsOrmEntity,
PenaltyOrmEntity,
ProtestOrmEntity,
@@ -155,6 +160,7 @@ const typeOrmFeatureImports = [
{ provide: TeamOrmMapper, useFactory: () => new TeamOrmMapper() },
{ provide: TeamMembershipOrmMapper, useFactory: () => new TeamMembershipOrmMapper() },
{ provide: TeamStatsOrmMapper, useFactory: () => new TeamStatsOrmMapper() },
{ provide: PenaltyOrmMapper, useFactory: () => new PenaltyOrmMapper() },
{ provide: ProtestOrmMapper, useFactory: () => new ProtestOrmMapper() },
@@ -321,8 +327,8 @@ const typeOrmFeatureImports = [
},
{
provide: TEAM_STATS_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryTeamStatsRepository(logger),
inject: ['Logger'],
useFactory: (repo: Repository<TeamStatsOrmEntity>, mapper: TeamStatsOrmMapper) => new TypeOrmTeamStatsRepository(repo, mapper),
inject: [getRepositoryToken(TeamStatsOrmEntity), TeamStatsOrmMapper],
},
{
provide: MEDIA_REPOSITORY_TOKEN,
@@ -356,4 +362,4 @@ const typeOrmFeatureImports = [
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' },
];
// ============================================================================
// 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
// ============================================================================
@@ -64,6 +83,7 @@ interface FeaturedDriverCardProps {
function FeaturedDriverCard({ driver, position, onClick }: FeaturedDriverCardProps) {
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
const categoryConfig = CATEGORIES.find((c) => c.id === driver.category);
const getBorderColor = (pos: number) => {
switch (pos) {
@@ -98,9 +118,16 @@ function FeaturedDriverCard({ driver, position, onClick }: FeaturedDriverCardPro
<span className="text-lg font-bold text-gray-400">#{position}</span>
)}
</div>
<span className={`px-2 py-1 rounded-full text-[10px] font-medium ${levelConfig?.bgColor} ${levelConfig?.color} border ${levelConfig?.borderColor}`}>
{levelConfig?.label}
</span>
<div className="flex gap-2">
{categoryConfig && (
<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>
{/* Avatar & Name */}
@@ -150,8 +177,8 @@ function SkillDistribution({ drivers }: SkillDistributionProps) {
const distribution = SKILL_LEVELS.map((level) => ({
...level,
count: drivers.filter((d) => d.skillLevel === level.id).length,
percentage: drivers.length > 0
? Math.round((drivers.filter((d) => d.skillLevel === level.id).length / drivers.length) * 100)
percentage: drivers.length > 0
? Math.round((drivers.filter((d) => d.skillLevel === level.id).length / drivers.length) * 100)
: 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
// ============================================================================
@@ -258,6 +345,7 @@ function LeaderboardPreview({ drivers, onDriverClick }: LeaderboardPreviewProps)
<div className="divide-y divide-charcoal-outline/50">
{top5.map((driver, index) => {
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
const categoryConfig = CATEGORIES.find((c) => c.id === driver.category);
const position = index + 1;
return (
@@ -285,6 +373,9 @@ function LeaderboardPreview({ drivers, onDriverClick }: LeaderboardPreviewProps)
<div className="flex items-center gap-2 text-xs text-gray-500">
<Flag className="w-3 h-3" />
{driver.nationality}
{categoryConfig && (
<span className={categoryConfig.color}>{categoryConfig.label}</span>
)}
<span className={levelConfig?.color}>{levelConfig?.label}</span>
</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">
{activeDrivers.map((driver) => {
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
const categoryConfig = CATEGORIES.find((c) => c.id === driver.category);
return (
<button
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">
{driver.name}
</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>
);
})}
@@ -516,6 +613,9 @@ export default function DriversPage() {
{/* Skill Distribution */}
{!searchQuery && <SkillDistribution drivers={drivers} />}
{/* Category Distribution */}
{!searchQuery && <CategoryDistribution drivers={drivers} />}
{/* Leaderboard Preview */}
<LeaderboardPreview drivers={filteredDrivers} onDriverClick={handleDriverClick} />

View File

@@ -422,7 +422,15 @@ export default function LeaguesPage() {
// Group leagues by category for slider view
const leaguesByCategory = CATEGORIES.reduce(
(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;
},
{} 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">
<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 && (
<span>
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>
<div className="space-y-3">
<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 && (
<StatItem label="Leagues" value={leagueCount.toString()} color="text-green-400" />
)}

View File

@@ -416,6 +416,12 @@ export default function TeamLeaderboardPage() {
</p>
<div className="flex items-center gap-2 text-xs text-gray-500 flex-wrap">
<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 && (
<span className="flex items-center gap-1 text-gray-400">
<Globe className="w-3 h-3 text-neon-aqua" />
@@ -497,4 +503,4 @@ export default function TeamLeaderboardPage() {
</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">
{team.name}
</p>
<div className="flex items-center gap-2 text-xs text-gray-500">
<Users className="w-3 h-3" />
{team.memberCount} members
<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">
<Users className="w-3 h-3" />
{team.memberCount} members
</span>
<span className={levelConfig?.color}>{levelConfig?.label}</span>
</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 {
switch (gameId) {
case 'iracing':
@@ -81,6 +123,8 @@ export default function LeagueCard({ league, onClick }: LeagueCardProps) {
const gameColorClass = getGameColor(league.scoring?.gameId);
const isNew = isNewLeague(league.createdAt);
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
const usedSlots = isTeamLeague ? (league.usedTeamSlots ?? 0) : (league.usedDriverSlots ?? 0);
@@ -128,6 +172,11 @@ export default function LeagueCard({ league, onClick }: LeagueCardProps) {
{league.scoring.gameName}
</span>
)}
{league.category && (
<span className={`px-2 py-0.5 rounded-full text-[10px] font-semibold border ${categoryColorClass}`}>
{categoryLabel}
</span>
)}
</div>
{/* Championship Type Badge - Top Right */}

View File

@@ -91,7 +91,13 @@ export default function FeaturedRecruiting({ teams, onTeamClick }: FeaturedRecru
</h3>
<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">
<Users className="w-3 h-3" />
{team.memberCount}

View File

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

View File

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

View File

@@ -140,7 +140,13 @@ export default function TeamLeaderboardPreview({
<p className="text-white font-medium truncate group-hover:text-purple-400 transition-colors">
{team.name}
</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">
<Users className="w-3 h-3" />
{team.memberCount}
@@ -161,7 +167,7 @@ export default function TeamLeaderboardPreview({
{/* Rating */}
<div className="text-right">
<p className="text-purple-400 font-mono font-semibold">
{'—'}
{typeof team.rating === 'number' ? Math.round(team.rating).toLocaleString() : '—'}
</p>
<p className="text-xs text-gray-500">Rating</p>
</div>
@@ -172,4 +178,4 @@ export default function TeamLeaderboardPreview({
</div>
</div>
);
}
}

View File

@@ -140,6 +140,13 @@ export default function TopThreePodium({ teams, onClick }: TopThreePodiumProps)
{team.name}
</p>
{/* Category */}
{team.category && (
<p className="text-xs text-purple-400 text-center mt-1">
{team.category}
</p>
)}
{/* Rating */}
<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>
);
}
}

View File

@@ -121,6 +121,7 @@ export class LeagueService {
structureSummary: league.scoring?.scoringPresetName ?? 'Custom rules',
scoringPatternSummary: league.scoring?.scoringPatternSummary,
timingSummary: league.timingSummary ?? '',
...(league.category ? { category: league.category } : {}),
...(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 { UploadMediaViewModel } from '@/lib/view-models/UploadMediaViewModel';
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
type UploadMediaRequest = { file: File; type: string; category?: string };
@@ -43,6 +44,7 @@ export class MediaService {
/**
* Get team logo URL
* Returns relative URL for proxying through Next.js rewrites
*/
getTeamLogo(teamId: string): string {
return `/api/media/teams/${teamId}/logo`;
@@ -50,6 +52,7 @@ export class MediaService {
/**
* Get driver avatar URL
* Returns relative URL for proxying through Next.js rewrites
*/
getDriverAvatar(driverId: string): string {
return `/api/media/avatar/${driverId}`;
@@ -57,6 +60,7 @@ export class MediaService {
/**
* Get league cover URL
* Returns relative URL for proxying through Next.js rewrites
*/
getLeagueCover(leagueId: string): string {
return `/api/media/leagues/${leagueId}/cover`;
@@ -64,6 +68,7 @@ export class MediaService {
/**
* Get league logo URL
* Returns relative URL for proxying through Next.js rewrites
*/
getLeagueLogo(leagueId: string): string {
return `/api/media/leagues/${leagueId}/logo`;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
/**
* Auto-generated DTO from OpenAPI spec
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
* Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* This file is generated by scripts/generate-api-types.ts
* 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