harden media

This commit is contained in:
2025-12-31 15:39:28 +01:00
parent 92226800df
commit 8260bf7baf
413 changed files with 8361 additions and 1544 deletions

View File

@@ -556,6 +556,15 @@
}
}
},
"/media/avatar/{driverId}/details": {
"get": {
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/media/avatar/generate": {
"post": {
"responses": {
@@ -574,6 +583,33 @@
}
}
},
"/media/debug/resolve": {
"get": {
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/media/default/{variant}": {
"get": {
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/media/generated/{type}/{id}": {
"get": {
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/media/leagues/{leagueId}/cover": {
"get": {
"responses": {
@@ -592,6 +628,15 @@
}
}
},
"/media/teams/{teamId}/logo": {
"get": {
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/media/upload": {
"post": {
"responses": {
@@ -601,6 +646,15 @@
}
}
},
"/media/uploaded/{mediaId}": {
"get": {
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/payments": {
"get": {
"responses": {
@@ -1746,7 +1800,12 @@
"type": "string"
},
"avatarUrl": {
"type": "string"
"type": "string",
"nullable": true
},
"category": {
"type": "string",
"nullable": true
},
"rating": {
"type": "number",
@@ -1774,7 +1833,6 @@
"id",
"name",
"country",
"avatarUrl",
"totalRaces",
"wins",
"podiums"
@@ -1843,14 +1901,14 @@
"type": "string"
},
"avatarUrl": {
"type": "string"
"type": "string",
"nullable": true
}
},
"required": [
"id",
"name",
"country",
"avatarUrl"
"country"
]
},
"DashboardLeagueStandingSummaryDTO": {
@@ -2064,6 +2122,9 @@
},
"joinedAt": {
"type": "string"
},
"category": {
"type": "string"
}
},
"required": [
@@ -2089,6 +2150,9 @@
"skillLevel": {
"type": "string"
},
"category": {
"type": "string"
},
"nationality": {
"type": "string"
},
@@ -2169,7 +2233,8 @@
"type": "string"
},
"avatarUrl": {
"type": "string"
"type": "string",
"nullable": true
},
"iracingId": {
"type": "string",
@@ -2178,6 +2243,10 @@
"joinedAt": {
"type": "string"
},
"category": {
"type": "string",
"nullable": true
},
"rating": {
"type": "number",
"nullable": true
@@ -2203,7 +2272,6 @@
"id",
"name",
"country",
"avatarUrl",
"joinedAt"
]
},
@@ -2300,14 +2368,14 @@
"type": "string"
},
"avatarUrl": {
"type": "string"
"type": "string",
"nullable": true
}
},
"required": [
"id",
"name",
"country",
"avatarUrl"
"country"
]
},
"DriverProfileSocialHandleDTO": {
@@ -2510,7 +2578,8 @@
"type": "string"
},
"avatarUrl": {
"type": "string"
"type": "string",
"nullable": true
},
"rating": {
"type": "number",
@@ -2660,12 +2729,10 @@
"type": "object",
"properties": {
"avatarUrl": {
"type": "string"
"type": "string",
"nullable": true
}
},
"required": [
"avatarUrl"
]
}
},
"GetDashboardDataOutputDTO": {
"type": "object",
@@ -2711,6 +2778,9 @@
"joinedAt": {
"type": "string"
},
"category": {
"type": "string"
},
"rating": {
"type": "number"
},
@@ -2725,6 +2795,10 @@
},
"totalRaces": {
"type": "number"
},
"avatarUrl": {
"type": "string",
"nullable": true
}
},
"required": [
@@ -4401,6 +4475,10 @@
"usedSlots": {
"type": "number"
},
"category": {
"type": "string",
"nullable": true
},
"socialLinks": {
"$ref": "#/components/schemas/LeagueCapacityAndScoringSocialLinksDTO",
"nullable": true
@@ -5012,7 +5090,8 @@
"type": "string"
},
"avatarUrl": {
"type": "string"
"type": "string",
"nullable": true
},
"rating": {
"type": "number",
@@ -5026,7 +5105,6 @@
"id",
"name",
"country",
"avatarUrl",
"isCurrentUser"
]
},
@@ -5311,7 +5389,8 @@
"type": "string"
},
"avatarUrl": {
"type": "string"
"type": "string",
"nullable": true
},
"position": {
"type": "number"
@@ -5338,7 +5417,6 @@
"required": [
"driverId",
"driverName",
"avatarUrl",
"position",
"startPosition",
"incidents",
@@ -5817,7 +5895,8 @@
"type": "string"
},
"avatarUrl": {
"type": "string"
"type": "string",
"nullable": true
}
},
"required": [
@@ -6008,7 +6087,8 @@
"type": "string"
},
"logoUrl": {
"type": "string"
"type": "string",
"nullable": true
},
"websiteUrl": {
"type": "string"
@@ -6080,7 +6160,8 @@
"type": "string"
},
"logoUrl": {
"type": "string"
"type": "string",
"nullable": true
},
"industry": {
"type": "string"
@@ -6481,6 +6562,12 @@
"type": "string"
}
},
"category": {
"type": "string"
},
"isRecruiting": {
"type": "boolean"
},
"createdAt": {
"type": "string"
}
@@ -6491,7 +6578,8 @@
"tag",
"description",
"ownerId",
"leagues"
"leagues",
"isRecruiting"
]
},
"TeamJoinRequestDTO": {
@@ -6516,7 +6604,8 @@
"type": "string"
},
"avatarUrl": {
"type": "string"
"type": "string",
"nullable": true
}
},
"required": [
@@ -6525,8 +6614,7 @@
"driverName",
"teamId",
"status",
"requestedAt",
"avatarUrl"
"requestedAt"
]
},
"TeamLeaderboardItemDTO": {
@@ -6632,11 +6720,18 @@
"performanceLevel": {
"type": "string"
},
"logoUrl": {
"category": {
"type": "string"
},
"logoUrl": {
"type": "string",
"nullable": true
},
"rating": {
"type": "number"
},
"isRecruiting": {
"type": "boolean"
}
},
"required": [
@@ -6645,7 +6740,8 @@
"tag",
"description",
"memberCount",
"leagues"
"leagues",
"isRecruiting"
]
},
"TeamMemberDTO": {
@@ -6667,7 +6763,8 @@
"type": "boolean"
},
"avatarUrl": {
"type": "string"
"type": "string",
"nullable": true
}
},
"required": [
@@ -6675,8 +6772,7 @@
"driverName",
"role",
"joinedAt",
"isActive",
"avatarUrl"
"isActive"
]
},
"TeamMembershipDTO": {
@@ -6765,12 +6861,12 @@
"type": "string"
},
"avatarUrl": {
"type": "string"
"type": "string",
"nullable": true
}
},
"required": [
"driverId",
"avatarUrl"
"driverId"
]
},
"UpdateAvatarOutputDTO": {

View File

@@ -21,6 +21,7 @@
"tsconfig-paths": "^3.15.0"
},
"dependencies": {
"@faker-js/faker": "^9.9.0",
"@nestjs/common": "^10.4.20",
"@nestjs/core": "^10.4.20",
"@nestjs/platform-express": "^10.4.20",

View File

@@ -27,8 +27,8 @@ export class SignupParamsDTO {
iracingCustomerId?: string;
@ApiProperty({ required: false })
primaryDriverId?: string;
@ApiProperty({ required: false })
avatarUrl?: string;
@ApiProperty({ required: false, nullable: true })
avatarUrl?: string | null;
}
export class LoginParamsDTO {

View File

@@ -74,21 +74,43 @@ export class BootstrapModule implements OnModuleInit {
}
private async needsReseed(): Promise<boolean> {
// Check if driver count is less than expected (150)
// This indicates old seed data that needs updating
// Check if entity counts are below expected thresholds
// This is conflict-proof: only checks durable entity presence, not transient media state
try {
// Expected counts based on seed data configuration
const EXPECTED_DRIVERS = 150;
const EXPECTED_TEAMS = 50; // Based on RacingTeamFactory logic
const EXPECTED_LEAGUES = 120; // Based on RacingLeagueFactory logic
// Check driver count
const drivers = await this.seedDeps.driverRepository.findAll();
const driverCount = drivers.length;
// If we have fewer than 150 drivers, we need to reseed
if (driverCount < 150) {
this.logger.info(`[Bootstrap] Found ${driverCount} drivers (expected 150), triggering reseed`);
if (driverCount < EXPECTED_DRIVERS) {
this.logger.info(`[Bootstrap] Found ${driverCount} drivers (expected ${EXPECTED_DRIVERS}), triggering reseed`);
return true;
}
// Check team count
const teams = await this.seedDeps.teamRepository.findAll();
const teamCount = teams.length;
if (teamCount < EXPECTED_TEAMS) {
this.logger.info(`[Bootstrap] Found ${teamCount} teams (expected ${EXPECTED_TEAMS}), triggering reseed`);
return true;
}
// Check league count
const leagues = await this.seedDeps.leagueRepository.findAll();
const leagueCount = leagues.length;
if (leagueCount < EXPECTED_LEAGUES) {
this.logger.info(`[Bootstrap] Found ${leagueCount} leagues (expected ${EXPECTED_LEAGUES}), triggering reseed`);
return true;
}
// All entity counts are sufficient - no reseed needed
this.logger.info(`[Bootstrap] Entity counts sufficient: ${driverCount} drivers, ${teamCount} teams, ${leagueCount} leagues`);
return false;
} catch (error) {
this.logger.warn('[Bootstrap] Error checking driver count for reseed:', error);
this.logger.warn('[Bootstrap] Error checking for reseed:', error);
return false;
}
}

View File

@@ -14,9 +14,9 @@ export class DashboardDriverSummaryDTO {
@IsString()
country!: string;
@ApiProperty()
@ApiProperty({ nullable: true })
@IsString()
avatarUrl!: string;
avatarUrl!: string | null;
@ApiProperty({ nullable: true })
@IsOptional()

View File

@@ -14,7 +14,7 @@ export class DashboardFriendSummaryDTO {
@IsString()
country!: string;
@ApiProperty()
@ApiProperty({ nullable: true })
@IsString()
avatarUrl!: string;
avatarUrl!: string | null;
}

View File

@@ -10,6 +10,7 @@ import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository';
import type { IStandingRepository } from '@core/racing/domain/repositories/IStandingRepository';
import type { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
// Import use cases
import { CompleteDriverOnboardingUseCase } from '@core/racing/application/use-cases/CompleteDriverOnboardingUseCase';
@@ -31,9 +32,9 @@ import { DriverStatsUseCase } from '@core/racing/application/use-cases/DriverSta
// Import new repositories
import { InMemoryDriverStatsRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverStatsRepository';
import { InMemoryMediaRepository } from '@adapters/racing/persistence/media/InMemoryMediaRepository';
import { MediaResolverAdapter } from '@adapters/media/MediaResolverAdapter';
// Import repository tokens
import { IDriverStatsRepository } from '@core/racing/domain/repositories/IDriverStatsRepository';
import { IMediaRepository } from '@core/racing/domain/repositories/IMediaRepository';
// Import use case interfaces
import type { IRankingUseCase } from '@core/racing/application/use-cases/IRankingUseCase';
import type { IDriverStatsUseCase } from '@core/racing/application/use-cases/IDriverStatsUseCase';
@@ -73,6 +74,7 @@ import {
MEDIA_REPOSITORY_TOKEN,
RANKING_SERVICE_TOKEN,
DRIVER_STATS_SERVICE_TOKEN,
MEDIA_RESOLVER_TOKEN,
} from './DriverTokens';
export * from './DriverTokens';
@@ -80,16 +82,34 @@ export * from './DriverTokens';
export const DriverProviders: Provider[] = [
// Presenters
DriversLeaderboardPresenter,
{
provide: DriversLeaderboardPresenter,
useFactory: (mediaResolver: MediaResolverPort) => {
const presenter = new DriversLeaderboardPresenter();
presenter.setMediaResolver(mediaResolver);
return presenter;
},
inject: [MEDIA_RESOLVER_TOKEN],
},
DriverStatsPresenter,
CompleteOnboardingPresenter,
DriverRegistrationStatusPresenter,
{
provide: DriverPresenter,
useFactory: (driverStatsRepository: IDriverStatsRepository) => new DriverPresenter(driverStatsRepository),
inject: [DRIVER_STATS_REPOSITORY_TOKEN],
useFactory: (driverStatsRepository: IDriverStatsRepository, mediaResolver: MediaResolverPort) => {
const presenter = new DriverPresenter(driverStatsRepository, mediaResolver);
return presenter;
},
inject: [DRIVER_STATS_REPOSITORY_TOKEN, MEDIA_RESOLVER_TOKEN],
},
{
provide: DriverProfilePresenter,
useFactory: (mediaResolver: MediaResolverPort) => {
const presenter = new DriverProfilePresenter(mediaResolver);
return presenter;
},
inject: [MEDIA_RESOLVER_TOKEN],
},
DriverProfilePresenter,
// Output ports (point to presenters)
{
@@ -123,6 +143,12 @@ export const DriverProviders: Provider[] = [
useClass: ConsoleLogger,
},
// Media Resolver (real adapter, path-only)
{
provide: MEDIA_RESOLVER_TOKEN,
useFactory: () => new MediaResolverAdapter({}),
},
// Repositories (racing + social repos are provided by imported persistence modules)
{
provide: DRIVER_STATS_REPOSITORY_TOKEN,
@@ -131,7 +157,22 @@ export const DriverProviders: Provider[] = [
},
{
provide: MEDIA_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryMediaRepository(logger),
useFactory: (logger: Logger) => {
const mediaRepo = new InMemoryMediaRepository(logger);
// Override getTeamLogo to provide fallback URLs
const originalGetTeamLogo = mediaRepo.getTeamLogo.bind(mediaRepo);
mediaRepo.getTeamLogo = async (teamId: string): Promise<string | null> => {
const logo = await originalGetTeamLogo(teamId);
if (logo) return logo;
// Fallback: generate deterministic team logo URL
// Use path-only URL
return `/media/teams/${teamId}/logo`;
};
return mediaRepo;
},
inject: [LOGGER_TOKEN],
},
{
@@ -180,21 +221,16 @@ export const DriverProviders: Provider[] = [
driverRepo: IDriverRepository,
rankingUseCase: IRankingUseCase,
driverStatsUseCase: IDriverStatsUseCase,
mediaRepository: IMediaRepository,
logger: Logger,
output: UseCaseOutputPort<unknown>,
) => new GetDriversLeaderboardUseCase(
driverRepo,
rankingUseCase,
driverStatsUseCase,
async (driverId: string) => {
const avatar = await mediaRepository.getDriverAvatar(driverId);
return avatar ?? undefined;
},
logger,
output
),
inject: [DRIVER_REPOSITORY_TOKEN, RANKING_SERVICE_TOKEN, DRIVER_STATS_SERVICE_TOKEN, MEDIA_REPOSITORY_TOKEN, LOGGER_TOKEN, GET_DRIVERS_LEADERBOARD_OUTPUT_PORT_TOKEN],
inject: [DRIVER_REPOSITORY_TOKEN, RANKING_SERVICE_TOKEN, DRIVER_STATS_SERVICE_TOKEN, LOGGER_TOKEN, GET_DRIVERS_LEADERBOARD_OUTPUT_PORT_TOKEN],
},
{
provide: GET_TOTAL_DRIVERS_USE_CASE_TOKEN,

View File

@@ -7,7 +7,18 @@ describe('DriverService', () => {
it('getDriversLeaderboard executes use case and returns presenter model', async () => {
const getDriversLeaderboardUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const driversLeaderboardPresenter = { getResponseModel: vi.fn(() => ({ items: [] })) };
const driversLeaderboardPresenter = {
setMediaResolver: vi.fn(),
setBaseUrl: vi.fn(),
present: vi.fn(),
getResponseModel: vi.fn(() => ({ items: [] }))
};
const driverPresenter = {
setMediaResolver: vi.fn(),
setBaseUrl: vi.fn(),
present: vi.fn(),
getResponseModel: vi.fn(() => null)
};
const service = new DriverService(
getDriversLeaderboardUseCase as any,
@@ -22,7 +33,7 @@ describe('DriverService', () => {
{ getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any,
{ getResponseModel: vi.fn(() => ({ success: true })) } as any,
{ getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any,
{ getResponseModel: vi.fn(() => null) } as any,
driverPresenter as any,
{ getResponseModel: vi.fn(() => ({ profile: {} })) } as any,
);
@@ -34,6 +45,12 @@ describe('DriverService', () => {
it('getTotalDrivers executes use case and returns presenter model', async () => {
const getTotalDriversUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const driverStatsPresenter = { getResponseModel: vi.fn(() => ({ totalDrivers: 123 })) };
const driverPresenter = {
setMediaResolver: vi.fn(),
setBaseUrl: vi.fn(),
present: vi.fn(),
getResponseModel: vi.fn(() => null)
};
const service = new DriverService(
{ execute: vi.fn() } as any,
@@ -48,7 +65,7 @@ describe('DriverService', () => {
driverStatsPresenter as any,
{ getResponseModel: vi.fn(() => ({ success: true })) } as any,
{ getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any,
{ getResponseModel: vi.fn(() => null) } as any,
driverPresenter as any,
{ getResponseModel: vi.fn(() => ({ profile: {} })) } as any,
);
@@ -59,6 +76,12 @@ describe('DriverService', () => {
it('completeOnboarding passes optional bio only when provided', async () => {
const completeDriverOnboardingUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const driverPresenter = {
setMediaResolver: vi.fn(),
setBaseUrl: vi.fn(),
present: vi.fn(),
getResponseModel: vi.fn(() => null)
};
const service = new DriverService(
{ execute: vi.fn() } as any,
@@ -73,7 +96,7 @@ describe('DriverService', () => {
{ getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any,
{ getResponseModel: vi.fn(() => ({ success: true })) } as any,
{ getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any,
{ getResponseModel: vi.fn(() => null) } as any,
driverPresenter as any,
{ getResponseModel: vi.fn(() => ({ profile: {} })) } as any,
);
@@ -115,6 +138,12 @@ describe('DriverService', () => {
it('getDriverRegistrationStatus passes raceId and driverId and returns presenter model', async () => {
const isDriverRegisteredForRaceUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const driverRegistrationStatusPresenter = { getResponseModel: vi.fn(() => ({ isRegistered: true })) };
const driverPresenter = {
setMediaResolver: vi.fn(),
setBaseUrl: vi.fn(),
present: vi.fn(),
getResponseModel: vi.fn(() => null)
};
const service = new DriverService(
{ execute: vi.fn() } as any,
@@ -129,7 +158,7 @@ describe('DriverService', () => {
{ getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any,
{ getResponseModel: vi.fn(() => ({ success: true })) } as any,
driverRegistrationStatusPresenter as any,
{ getResponseModel: vi.fn(() => null) } as any,
driverPresenter as any,
{ getResponseModel: vi.fn(() => ({ profile: {} })) } as any,
);
@@ -143,7 +172,12 @@ describe('DriverService', () => {
it('getCurrentDriver calls repository and returns presenter model', async () => {
const driverRepository = { findById: vi.fn(async () => null) };
const driverPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => null) };
const driverPresenter = {
setMediaResolver: vi.fn(),
setBaseUrl: vi.fn(),
present: vi.fn(),
getResponseModel: vi.fn(() => null)
};
const service = new DriverService(
{ execute: vi.fn() } as any,
@@ -169,7 +203,12 @@ describe('DriverService', () => {
it('updateDriverProfile builds optional input and returns presenter model', async () => {
const updateDriverProfileUseCase = { execute: vi.fn(async () => {}) };
const driverPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ driver: { id: 'd1' } })) };
const driverPresenter = {
setMediaResolver: vi.fn(),
setBaseUrl: vi.fn(),
present: vi.fn(),
getResponseModel: vi.fn(() => ({ driver: { id: 'd1' } }))
};
const service = new DriverService(
{ execute: vi.fn() } as any,
@@ -211,7 +250,12 @@ describe('DriverService', () => {
it('getDriver calls repository and returns presenter model', async () => {
const driverRepository = { findById: vi.fn(async () => null) };
const driverPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ driver: null })) };
const driverPresenter = {
setMediaResolver: vi.fn(),
setBaseUrl: vi.fn(),
present: vi.fn(),
getResponseModel: vi.fn(() => ({ driver: null }))
};
const service = new DriverService(
{ execute: vi.fn() } as any,
@@ -237,7 +281,17 @@ describe('DriverService', () => {
it('getDriverProfile executes use case and returns presenter model', async () => {
const getProfileOverviewUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const driverProfilePresenter = { getResponseModel: vi.fn(() => ({ profile: { id: 'd1' } })) };
const driverProfilePresenter = {
setMediaResolver: vi.fn(),
setBaseUrl: vi.fn(),
getResponseModel: vi.fn(() => ({ profile: { id: 'd1' } }))
};
const driverPresenter = {
setMediaResolver: vi.fn(),
setBaseUrl: vi.fn(),
present: vi.fn(),
getResponseModel: vi.fn(() => null)
};
const service = new DriverService(
{ execute: vi.fn() } as any,
@@ -252,7 +306,7 @@ describe('DriverService', () => {
{ getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any,
{ getResponseModel: vi.fn(() => ({ success: true })) } as any,
{ getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any,
{ getResponseModel: vi.fn(() => null) } as any,
driverPresenter as any,
driverProfilePresenter as any,
);

View File

@@ -38,7 +38,6 @@ import {
LOGGER_TOKEN,
UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN,
} from './DriverTokens';
@Injectable()
export class DriverService {
constructor(
@@ -58,14 +57,16 @@ export class DriverService {
private readonly driverRepository: IDriverRepository, // TODO must be removed from service
@Inject(LOGGER_TOKEN)
private readonly logger: Logger,
// Injected presenters
private readonly driversLeaderboardPresenter: DriversLeaderboardPresenter,
private readonly driverStatsPresenter: DriverStatsPresenter,
private readonly completeOnboardingPresenter: CompleteOnboardingPresenter,
private readonly driverRegistrationStatusPresenter: DriverRegistrationStatusPresenter,
private readonly driverPresenter: DriverPresenter,
private readonly driverProfilePresenter: DriverProfilePresenter,
) {}
// Injected presenters (optional for module test compatibility)
private readonly driversLeaderboardPresenter?: DriversLeaderboardPresenter,
private readonly driverStatsPresenter?: DriverStatsPresenter,
private readonly completeOnboardingPresenter?: CompleteOnboardingPresenter,
private readonly driverRegistrationStatusPresenter?: DriverRegistrationStatusPresenter,
private readonly driverPresenter?: DriverPresenter,
private readonly driverProfilePresenter?: DriverProfilePresenter,
) {
// Presenters are configured by providers, no need to configure here
}
async getDriversLeaderboard(): Promise<DriversLeaderboardDTO> {
this.logger.debug('[DriverService] Fetching drivers leaderboard.');
@@ -74,7 +75,7 @@ export class DriverService {
if (result.isErr()) {
throw new Error(result.unwrapErr().details.message);
}
return this.driversLeaderboardPresenter.getResponseModel();
return this.driversLeaderboardPresenter!.getResponseModel();
}
async getTotalDrivers(): Promise<DriverStatsDTO> {
@@ -84,7 +85,7 @@ export class DriverService {
if (result.isErr()) {
throw new Error(result.unwrapErr().details.message);
}
return this.driverStatsPresenter.getResponseModel();
return this.driverStatsPresenter!.getResponseModel();
}
async completeOnboarding(
@@ -105,7 +106,7 @@ export class DriverService {
if (result.isErr()) {
throw new Error(result.unwrapErr().details.message);
}
return this.completeOnboardingPresenter.getResponseModel();
return this.completeOnboardingPresenter!.getResponseModel();
}
async getDriverRegistrationStatus(
@@ -121,15 +122,15 @@ export class DriverService {
if (result.isErr()) {
throw new Error(result.unwrapErr().details.message);
}
return this.driverRegistrationStatusPresenter.getResponseModel();
return this.driverRegistrationStatusPresenter!.getResponseModel();
}
async getCurrentDriver(userId: string): Promise<GetDriverOutputDTO | null> {
this.logger.debug(`[DriverService] Fetching current driver for userId: ${userId}`);
const driver = await this.driverRepository.findById(userId);
this.driverPresenter.present(Result.ok(driver));
return this.driverPresenter.getResponseModel();
await this.driverPresenter!.present(Result.ok(driver));
return this.driverPresenter!.getResponseModel();
}
async updateDriverProfile(
@@ -144,15 +145,19 @@ export class DriverService {
if (country !== undefined) input.country = country;
await this.updateDriverProfileUseCase.execute(input);
return this.driverPresenter.getResponseModel();
// Get the updated driver and present it
const driver = await this.driverRepository.findById(driverId);
await this.driverPresenter!.present(Result.ok(driver));
return this.driverPresenter!.getResponseModel();
}
async getDriver(driverId: string): Promise<GetDriverOutputDTO | null> {
this.logger.debug(`[DriverService] Fetching driver for driverId: ${driverId}`);
const driver = await this.driverRepository.findById(driverId);
this.driverPresenter.present(Result.ok(driver));
return this.driverPresenter.getResponseModel();
await this.driverPresenter!.present(Result.ok(driver));
return this.driverPresenter!.getResponseModel();
}
async getDriverProfile(driverId: string): Promise<GetDriverProfileOutputDTO> {
@@ -162,6 +167,6 @@ export class DriverService {
if (result.isErr()) {
throw new Error(result.unwrapErr().details.message);
}
return this.driverProfilePresenter.getResponseModel();
return this.driverProfilePresenter!.getResponseModel();
}
}

View File

@@ -16,6 +16,7 @@ export const LOGGER_TOKEN = 'Logger';
// New tokens for clean architecture
export const DRIVER_STATS_REPOSITORY_TOKEN = 'IDriverStatsRepository';
export const MEDIA_REPOSITORY_TOKEN = 'IMediaRepository';
export const MEDIA_RESOLVER_TOKEN = 'MediaResolverPort';
export const GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN = 'GetDriversLeaderboardUseCase';
export const GET_TOTAL_DRIVERS_USE_CASE_TOKEN = 'GetTotalDriversUseCase';

View File

@@ -35,5 +35,5 @@ export class DriverLeaderboardItemDTO {
rank!: number;
@ApiProperty({ nullable: true })
avatarUrl?: string;
avatarUrl!: string | null;
}

View File

@@ -10,8 +10,8 @@ export class DriverProfileDriverSummaryDTO {
@ApiProperty()
country!: string;
@ApiProperty()
avatarUrl!: string;
@ApiProperty({ nullable: true })
avatarUrl!: string | null;
@ApiProperty({ nullable: true })
iracingId!: string | null;

View File

@@ -10,6 +10,6 @@ export class DriverProfileSocialFriendSummaryDTO {
@ApiProperty()
country!: string;
@ApiProperty()
avatarUrl!: string;
@ApiProperty({ nullable: true })
avatarUrl!: string | null;
}

View File

@@ -36,4 +36,7 @@ export class GetDriverOutputDTO {
@ApiProperty({ required: false })
totalRaces?: number;
@ApiProperty({ nullable: true })
avatarUrl!: string | null;
}

View File

@@ -2,16 +2,26 @@ import { Result } from '@core/shared/application/Result';
import type { Driver } from '@core/racing/domain/entities/Driver';
import type { GetDriverOutputDTO } from '../dtos/GetDriverOutputDTO';
import type { IDriverStatsRepository } from '@core/racing/domain/repositories/IDriverStatsRepository';
import type { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
import { MediaReference } from '@core/domain/media/MediaReference';
export class DriverPresenter {
private responseModel: GetDriverOutputDTO | null = null;
private mediaResolver: MediaResolverPort | undefined;
constructor(
private readonly driverStatsRepository: IDriverStatsRepository
) {}
private readonly driverStatsRepository: IDriverStatsRepository,
mediaResolver?: MediaResolverPort
) {
this.mediaResolver = mediaResolver;
}
setMediaResolver(resolver: MediaResolverPort | undefined): void {
this.mediaResolver = resolver;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
present(result: Result<Driver | null, any>): void {
async present(result: Result<Driver | null, any>): Promise<void> {
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Failed to get driver');
@@ -26,12 +36,22 @@ export class DriverPresenter {
// Get stats from repository (synchronously for now, could be async)
const stats = this.driverStatsRepository.getDriverStatsSync(driver.id);
this.responseModel = {
// Resolve avatar URL using MediaResolverPort
let avatarUrl: string | null = null;
if (this.mediaResolver) {
const ref = driver.avatarRef ?? MediaReference.createNone();
const resolvedRef = ref instanceof MediaReference ? ref : MediaReference.fromJSON(ref);
const resolvedUrl = await this.mediaResolver.resolve(resolvedRef);
avatarUrl = resolvedUrl ?? null;
}
const dto: GetDriverOutputDTO = {
id: driver.id,
iracingId: driver.iracingId.toString(),
name: driver.name.toString(),
country: driver.country.toString(),
joinedAt: driver.joinedAt.toDate().toISOString(),
avatarUrl,
...(driver.bio ? { bio: driver.bio.toString() } : {}),
...(driver.category ? { category: driver.category } : {}),
// Add stats fields
@@ -43,6 +63,8 @@ export class DriverPresenter {
experienceLevel: this.getExperienceLevel(stats.rating),
} : {}),
};
this.responseModel = dto;
}
getResponseModel(): GetDriverOutputDTO | null {
@@ -55,4 +77,4 @@ export class DriverPresenter {
if (rating >= 1000) return 'intermediate';
return 'beginner';
}
}
}

View File

@@ -3,19 +3,49 @@ import type {
} from '@core/racing/application/use-cases/GetProfileOverviewUseCase';
import type { GetDriverProfileOutputDTO } from '../dtos/GetDriverProfileOutputDTO';
import type { DriverProfileExtendedProfileDTO } from '../dtos/DriverProfileExtendedProfileDTO';
import type { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
import { MediaReference } from '@core/domain/media/MediaReference';
export class DriverProfilePresenter
{
export class DriverProfilePresenter {
private responseModel: GetDriverProfileOutputDTO | null = null;
private mediaResolver: MediaResolverPort | undefined;
constructor(mediaResolver?: MediaResolverPort) {
this.mediaResolver = mediaResolver;
}
setMediaResolver(resolver: MediaResolverPort | undefined): void {
this.mediaResolver = resolver;
}
async present(result: GetProfileOverviewResult): Promise<void> {
// Resolve current driver avatar
let currentDriverAvatarUrl: string | null = null;
if (this.mediaResolver && result.driverInfo?.driver.avatarRef) {
const ref = result.driverInfo.driver.avatarRef instanceof MediaReference
? result.driverInfo.driver.avatarRef
: MediaReference.fromJSON(result.driverInfo.driver.avatarRef);
currentDriverAvatarUrl = await this.mediaResolver.resolve(ref);
}
// Resolve friend avatars
let friendAvatars: Record<string, string | null> = {};
if (this.mediaResolver) {
for (const friend of result.socialSummary.friends) {
const ref = friend.avatarRef instanceof MediaReference
? friend.avatarRef
: MediaReference.fromJSON(friend.avatarRef);
friendAvatars[friend.id] = await this.mediaResolver.resolve(ref);
}
}
present(result: GetProfileOverviewResult): void {
this.responseModel = {
currentDriver: result.driverInfo
? {
id: result.driverInfo.driver.id,
name: result.driverInfo.driver.name.toString(),
country: result.driverInfo.driver.country.toString(),
avatarUrl: this.getAvatarUrl(result.driverInfo.driver.id) || '',
avatarUrl: currentDriverAvatarUrl,
iracingId: result.driverInfo.driver.iracingId.toString(),
joinedAt: result.driverInfo.driver.joinedAt.toDate().toISOString(),
category: result.driverInfo.driver.category || null,
@@ -42,7 +72,7 @@ export class DriverProfilePresenter
id: friend.id,
name: friend.name.toString(),
country: friend.country.toString(),
avatarUrl: '', // TODO: get avatar
avatarUrl: friendAvatars[friend.id] ?? null,
})),
},
extendedProfile: result.extendedProfile as DriverProfileExtendedProfileDTO | null,
@@ -53,11 +83,4 @@ export class DriverProfilePresenter
if (!this.responseModel) throw new Error('Presenter not presented');
return this.responseModel;
}
private getAvatarUrl(driverId: string): string | undefined {
void driverId;
// Avatar resolution is delegated to infrastructure; keep as-is for now.
return undefined;
}
}
}

View File

@@ -1,20 +1,53 @@
import { GetDriversLeaderboardResult } from '@core/racing/application/use-cases/GetDriversLeaderboardUseCase';
import { beforeEach, describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DriversLeaderboardPresenter } from './DriversLeaderboardPresenter';
import type { Driver } from '@core/racing/domain/entities/Driver';
import type { SkillLevel } from '@core/racing/domain/services/SkillLevelService';
// TODO fix eslint issues
import { MediaReference } from '@core/domain/media/MediaReference';
import type { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
describe('DriversLeaderboardPresenter', () => {
let presenter: DriversLeaderboardPresenter;
let mockResolver: MediaResolverPort;
beforeEach(() => {
mockResolver = {
resolve: vi.fn().mockImplementation(async (ref) => {
if (ref.type === 'uploaded') {
return `/media/uploaded/${ref.mediaId}`;
}
if (ref.type === 'generated') {
// Parse generationRequestId to determine path
const requestId = ref.generationRequestId;
if (!requestId) return null;
const firstHyphenIndex = requestId.indexOf('-');
if (firstHyphenIndex === -1) return null;
const type = requestId.substring(0, firstHyphenIndex);
const id = requestId.substring(firstHyphenIndex + 1);
if (type === 'driver') {
return `/media/avatar/${id}`;
} else if (type === 'team') {
return `/media/teams/${id}/logo`;
} else if (type === 'league') {
return `/media/leagues/${id}/logo`;
}
return `/media/generated/${requestId}`;
}
if (ref.type === 'system-default') {
return `/media/default/${ref.variant}`;
}
return null;
}),
};
presenter = new DriversLeaderboardPresenter();
presenter.setMediaResolver(mockResolver);
});
describe('present', () => {
it('should map core result to API response model correctly', () => {
it('should resolve avatarRef to avatarUrl in API response', async () => {
const coreResult: GetDriversLeaderboardResult = {
items: [
{
@@ -30,7 +63,7 @@ describe('DriversLeaderboardPresenter', () => {
podiums: 20,
isActive: true,
rank: 1,
avatarUrl: 'https://example.com/avatar1.png',
avatarRef: MediaReference.createUploaded('avatar-1'),
},
{
driver: {
@@ -45,7 +78,7 @@ describe('DriversLeaderboardPresenter', () => {
podiums: 15,
isActive: true,
rank: 2,
avatarUrl: 'https://example.com/avatar2.png',
avatarRef: MediaReference.createGenerated('driver-2'),
},
],
totalRaces: 90,
@@ -53,7 +86,7 @@ describe('DriversLeaderboardPresenter', () => {
activeCount: 2,
};
presenter.present(coreResult);
await presenter.present(coreResult);
const output = presenter.getResponseModel();
@@ -69,7 +102,7 @@ describe('DriversLeaderboardPresenter', () => {
podiums: 20,
isActive: true,
rank: 1,
avatarUrl: 'https://example.com/avatar1.png',
avatarUrl: '/media/uploaded/avatar-1',
});
expect(output.drivers[1]).toEqual({
id: 'driver-2',
@@ -82,12 +115,75 @@ describe('DriversLeaderboardPresenter', () => {
podiums: 15,
isActive: true,
rank: 2,
avatarUrl: 'https://example.com/avatar2.png',
avatarUrl: '/media/avatar/2',
});
expect(output.totalRaces).toBe(90);
expect(output.totalWins).toBe(15);
expect(output.activeCount).toBe(2);
});
it('should handle missing avatarRef as null avatarUrl', async () => {
const coreResult: GetDriversLeaderboardResult = {
items: [
{
driver: {
id: 'driver-1',
name: 'Driver One',
country: 'US',
} as unknown as Driver,
rating: 2500,
skillLevel: 'advanced' as unknown as SkillLevel,
racesCompleted: 50,
wins: 10,
podiums: 20,
isActive: true,
rank: 1,
// avatarRef is undefined (not provided)
},
],
totalRaces: 50,
totalWins: 10,
activeCount: 1,
};
await presenter.present(coreResult);
const output = presenter.getResponseModel();
expect(output.drivers).toHaveLength(1);
expect(output.drivers[0]!.avatarUrl).toBeNull();
});
it('should handle system-default avatarRef', async () => {
const coreResult: GetDriversLeaderboardResult = {
items: [
{
driver: {
id: 'driver-1',
name: 'Driver One',
country: 'US',
} as unknown as Driver,
rating: 2500,
skillLevel: 'advanced' as unknown as SkillLevel,
racesCompleted: 50,
wins: 10,
podiums: 20,
isActive: true,
rank: 1,
avatarRef: MediaReference.createSystemDefault('avatar'),
},
],
totalRaces: 50,
totalWins: 10,
activeCount: 1,
};
await presenter.present(coreResult);
const output = presenter.getResponseModel();
expect(output.drivers).toHaveLength(1);
expect(output.drivers[0]!.avatarUrl).toBe('/media/default/avatar');
});
});
});

View File

@@ -2,26 +2,49 @@ import { DriversLeaderboardDTO } from '../dtos/DriversLeaderboardDTO';
import type {
GetDriversLeaderboardResult,
} from '@core/racing/application/use-cases/GetDriversLeaderboardUseCase';
import type { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
import { MediaReference } from '@core/domain/media/MediaReference';
export class DriversLeaderboardPresenter {
private responseModel: DriversLeaderboardDTO | null = null;
private mediaResolver?: MediaResolverPort;
setMediaResolver(resolver: MediaResolverPort): void {
this.mediaResolver = resolver;
}
async present(data: GetDriversLeaderboardResult): Promise<void> {
const drivers = await Promise.all(
data.items.map(async (item) => {
// Resolve avatar URL using MediaResolverPort if available
let avatarUrl: string | null = null;
if (this.mediaResolver && item.avatarRef) {
const ref = item.avatarRef instanceof MediaReference ? item.avatarRef : MediaReference.fromJSON(item.avatarRef);
const resolvedUrl = await this.mediaResolver.resolve(ref);
if (resolvedUrl) {
avatarUrl = resolvedUrl;
}
}
return {
id: item.driver.id,
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,
podiums: item.podiums,
isActive: item.isActive,
rank: item.rank,
avatarUrl,
};
})
);
present(data: GetDriversLeaderboardResult): void {
this.responseModel = {
drivers: data.items.map(item => ({
id: item.driver.id,
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,
podiums: item.podiums,
isActive: item.isActive,
rank: item.rank,
...(item.avatarUrl !== undefined ? { avatarUrl: item.avatarUrl } : {}),
})),
drivers,
totalRaces: data.totalRaces,
totalWins: data.totalWins,
activeCount: data.activeCount,

View File

@@ -13,6 +13,7 @@ import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeason
import type { ISeasonSponsorshipRepository } from '@core/racing/domain/repositories/ISeasonSponsorshipRepository';
import type { IStandingRepository } from '@core/racing/domain/repositories/IStandingRepository';
import type { Logger } from '@core/shared/application/Logger';
import type { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
// Import concrete in-memory implementations
import type { ILeagueWalletRepository } from "@core/racing/domain/repositories/ILeagueWalletRepository";
@@ -20,6 +21,7 @@ import type { ITransactionRepository } from "@core/racing/domain/repositories/IT
import { getLeagueScoringPresetById, listLeagueScoringPresets } from '@adapters/bootstrap/LeagueScoringPresets';
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
import { InMemoryLeagueStandingsRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueStandingsRepository';
import { MediaResolverAdapter } from '@adapters/media/MediaResolverAdapter';
// Import use cases
import { ApproveLeagueJoinRequestUseCase } from '@core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase';
@@ -110,6 +112,8 @@ export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
export const LEAGUE_WALLET_REPOSITORY_TOKEN = 'ILeagueWalletRepository';
export const TRANSACTION_REPOSITORY_TOKEN = 'ITransactionRepository';
export const LOGGER_TOKEN = 'Logger';
export const MEDIA_RESOLVER_TOKEN = 'MediaResolverPort';
export const GET_LEAGUE_STANDINGS_USE_CASE = 'GetLeagueStandingsUseCase';
export const GET_ALL_LEAGUES_WITH_CAPACITY_USE_CASE = 'GetAllLeaguesWithCapacityUseCase';
export const GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_USE_CASE = 'GetAllLeaguesWithCapacityAndScoringUseCase';
@@ -177,9 +181,21 @@ export const LeagueProviders: Provider[] = [
provide: LOGGER_TOKEN,
useClass: ConsoleLogger,
},
{
provide: MEDIA_RESOLVER_TOKEN,
useFactory: () => new MediaResolverAdapter({}),
},
// Presenters
AllLeaguesWithCapacityPresenter,
AllLeaguesWithCapacityAndScoringPresenter,
{
provide: AllLeaguesWithCapacityAndScoringPresenter,
useFactory: (mediaResolver: MediaResolverPort) => {
const presenter = new AllLeaguesWithCapacityAndScoringPresenter();
presenter.setMediaResolver(mediaResolver);
return presenter;
},
inject: [MEDIA_RESOLVER_TOKEN],
},
ApproveLeagueJoinRequestPresenter,
CreateLeaguePresenter,
GetLeagueAdminPermissionsPresenter,

View File

@@ -319,6 +319,9 @@ export class LeagueService {
throw new Error(err.code);
}
// The use case calls presenter.present() internally
// The presenter now handles logo resolution synchronously
// Just get the view model which contains the resolved logo URLs
return this.allLeaguesWithCapacityAndScoringPresenter.getViewModel();
}

View File

@@ -11,6 +11,7 @@ export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
export const LEAGUE_WALLET_REPOSITORY_TOKEN = 'ILeagueWalletRepository';
export const TRANSACTION_REPOSITORY_TOKEN = 'ITransactionRepository';
export const LOGGER_TOKEN = 'Logger';
export const MEDIA_RESOLVER_TOKEN = 'MediaResolverPort';
export const GET_LEAGUE_STANDINGS_USE_CASE = 'GetLeagueStandingsUseCase';
export const GET_ALL_LEAGUES_WITH_CAPACITY_USE_CASE = 'GetAllLeaguesWithCapacityUseCase';

View File

@@ -116,6 +116,11 @@ export class LeagueWithCapacityAndScoringDTO {
@IsOptional()
@IsString()
timingSummary?: string;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
logoUrl?: string | null;
}
export class AllLeaguesWithCapacityAndScoringDTO {

View File

@@ -18,7 +18,7 @@ export class LeagueSummaryDTO {
@ApiProperty({ nullable: true })
@IsOptional()
@IsString()
logoUrl?: string;
logoUrl!: string | null;
@ApiProperty({ nullable: true })
@IsOptional()

View File

@@ -4,52 +4,92 @@ import type {
AllLeaguesWithCapacityAndScoringDTO,
LeagueWithCapacityAndScoringDTO,
} from '../dtos/AllLeaguesWithCapacityAndScoringDTO';
import type { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
import { MediaReference } from '@core/domain/media/MediaReference';
export class AllLeaguesWithCapacityAndScoringPresenter
implements UseCaseOutputPort<GetAllLeaguesWithCapacityAndScoringResult>
{
private result: AllLeaguesWithCapacityAndScoringDTO | null = null;
private mediaResolver?: MediaResolverPort;
present(result: GetAllLeaguesWithCapacityAndScoringResult): void {
const leagues: LeagueWithCapacityAndScoringDTO[] = result.leagues.map((summary) => {
const timingSummary = summary.preset
? formatTimingSummary(summary.preset.defaultTimings.mainRaceMinutes)
: undefined;
setMediaResolver(resolver: MediaResolverPort): void {
this.mediaResolver = resolver;
}
return {
id: summary.league.id.toString(),
name: summary.league.name.toString(),
description: summary.league.description?.toString() || '',
ownerId: summary.league.ownerId.toString(),
createdAt: summary.league.createdAt.toDate().toISOString(),
settings: {
maxDrivers: summary.maxDrivers,
...(summary.league.settings.sessionDuration !== undefined
? { sessionDuration: summary.league.settings.sessionDuration }
: {}),
...(summary.league.settings.qualifyingFormat !== undefined
? { qualifyingFormat: summary.league.settings.qualifyingFormat.toString() }
: {}),
},
usedSlots: summary.currentDrivers,
...(summary.league.category ? { category: summary.league.category } : {}),
...mapSocialLinks(summary.league.socialLinks),
...(summary.scoringConfig && summary.game && summary.preset
? {
scoring: {
gameId: summary.game.id.toString(),
gameName: summary.game.name.toString(),
primaryChampionshipType: summary.preset.primaryChampionshipType,
scoringPresetId: summary.scoringConfig.scoringPresetId?.toString() ?? 'custom',
scoringPresetName: summary.preset.name,
dropPolicySummary: summary.preset.dropPolicySummary,
scoringPatternSummary: summary.preset.sessionSummary,
},
async present(result: GetAllLeaguesWithCapacityAndScoringResult): Promise<void> {
const leagues: LeagueWithCapacityAndScoringDTO[] = await Promise.all(
result.leagues.map(async (summary) => {
const timingSummary = summary.preset
? formatTimingSummary(summary.preset.defaultTimings.mainRaceMinutes)
: undefined;
// Resolve logo URL
let logoUrl: string | null | undefined;
if (summary.league.logoRef) {
const ref = summary.league.logoRef instanceof MediaReference
? summary.league.logoRef
: MediaReference.fromJSON(summary.league.logoRef);
if (this.mediaResolver) {
logoUrl = await this.mediaResolver.resolve(ref);
} else {
// Fallback to manual construction
if (ref.type === 'generated' && ref.generationRequestId) {
const requestId = ref.generationRequestId;
const firstHyphenIndex = requestId.indexOf('-');
if (firstHyphenIndex !== -1) {
const type = requestId.substring(0, firstHyphenIndex);
const id = requestId.substring(firstHyphenIndex + 1);
if (type === 'league') {
logoUrl = `/media/leagues/${id}/logo`;
}
}
} else if (ref.type === 'uploaded' && ref.mediaId) {
logoUrl = `/media/uploaded/${ref.mediaId}`;
} else if (ref.type === 'system-default') {
logoUrl = null;
}
: {}),
}
}
return {
id: summary.league.id.toString(),
name: summary.league.name.toString(),
description: summary.league.description?.toString() || '',
ownerId: summary.league.ownerId.toString(),
createdAt: summary.league.createdAt.toDate().toISOString(),
settings: {
maxDrivers: summary.maxDrivers,
...(summary.league.settings.sessionDuration !== undefined
? { sessionDuration: summary.league.settings.sessionDuration }
: {}),
...(summary.league.settings.qualifyingFormat !== undefined
? { qualifyingFormat: summary.league.settings.qualifyingFormat.toString() }
: {}),
},
usedSlots: summary.currentDrivers,
...(summary.league.category ? { category: summary.league.category } : {}),
...mapSocialLinks(summary.league.socialLinks),
...(summary.scoringConfig && summary.game && summary.preset
? {
scoring: {
gameId: summary.game.id.toString(),
gameName: summary.game.name.toString(),
primaryChampionshipType: summary.preset.primaryChampionshipType,
scoringPresetId: summary.scoringConfig.scoringPresetId?.toString() ?? 'custom',
scoringPresetName: summary.preset.name,
dropPolicySummary: summary.preset.dropPolicySummary,
scoringPatternSummary: summary.preset.sessionSummary,
},
}
: {}),
...(timingSummary ? { timingSummary } : {}),
};
});
...(logoUrl !== undefined ? { logoUrl } : {}),
};
})
);
this.result = {
leagues,

View File

@@ -0,0 +1,48 @@
import 'reflect-metadata';
import { Test } from '@nestjs/testing';
import type { TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
describe('Default avatar assets (HTTP)', () => {
const originalEnv = { ...process.env };
let module: TestingModule | undefined;
let app: any;
beforeAll(async () => {
vi.resetModules();
process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory';
process.env.GRIDPILOT_API_BOOTSTRAP = 'false';
delete process.env.DATABASE_URL;
const { AppModule } = await import('../../app.module');
module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
await app.init();
}, 20_000);
afterAll(async () => {
await app?.close();
await module?.close();
process.env = originalEnv;
vi.restoreAllMocks();
});
it('serves male/female/neutral default avatar files from website public assets', async () => {
const variants = ['male-default-avatar', 'female-default-avatar', 'neutral-default-avatar'] as const;
for (const v of variants) {
const res = await request(app.getHttpServer()).get(`/media/default/${v}`).expect(200);
expect(res.headers['content-type']).toMatch(/image\/(jpeg|jpg)/);
expect(Number(res.headers['content-length'] ?? 0)).toBeGreaterThan(0);
}
});
});

View File

@@ -22,6 +22,9 @@ import { GetAvatarOutputDTO } from './dtos/GetAvatarOutputDTO';
import { UpdateAvatarOutputDTO } from './dtos/UpdateAvatarOutputDTO';
import { UpdateAvatarInputDTO } from './dtos/UpdateAvatarInputDTO';
import type { MulterFile } from './types/MulterFile';
import { MediaGenerationService } from '@core/media/domain/services/MediaGenerationService';
import { MediaResolverAdapter } from '@adapters/media/MediaResolverAdapter';
import { LOGGER_TOKEN, MEDIA_STORAGE_PORT_TOKEN } from './MediaTokens';
describe('MediaController', () => {
let controller: MediaController;
@@ -33,6 +36,13 @@ describe('MediaController', () => {
getAvatar: ReturnType<typeof vi.fn>;
updateAvatar: ReturnType<typeof vi.fn>;
};
let generationService: MediaGenerationService & {
generateDriverAvatar: ReturnType<typeof vi.fn>;
generateTeamLogo: ReturnType<typeof vi.fn>;
generateLeagueLogo: ReturnType<typeof vi.fn>;
generateLeagueCover: ReturnType<typeof vi.fn>;
generateDefaultPNG: ReturnType<typeof vi.fn>;
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -49,24 +59,54 @@ describe('MediaController', () => {
updateAvatar: vi.fn(),
},
},
{
provide: MediaGenerationService,
useValue: {
generateDriverAvatar: vi.fn(),
generateTeamLogo: vi.fn(),
generateLeagueLogo: vi.fn(),
generateLeagueCover: vi.fn(),
generateDefaultPNG: vi.fn(),
},
},
{
provide: MediaResolverAdapter,
useValue: {
resolve: vi.fn(),
},
},
{
provide: LOGGER_TOKEN,
useValue: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
},
{
provide: MEDIA_STORAGE_PORT_TOKEN,
useValue: {
uploadMedia: vi.fn(),
deleteMedia: vi.fn(),
getBytes: vi.fn(),
getMetadata: vi.fn(),
},
},
],
}).compile();
controller = module.get<MediaController>(MediaController);
service = module.get(MediaService) as MediaService & {
requestAvatarGeneration: ReturnType<typeof vi.fn>;
uploadMedia: ReturnType<typeof vi.fn>;
getMedia: ReturnType<typeof vi.fn>;
deleteMedia: ReturnType<typeof vi.fn>;
getAvatar: ReturnType<typeof vi.fn>;
updateAvatar: ReturnType<typeof vi.fn>;
};
service = module.get(MediaService) as any;
generationService = module.get(MediaGenerationService) as any;
});
const createMockResponse = (): Response => {
const res: Partial<Response> = {};
res.status = vi.fn().mockReturnValue(res as Response);
res.json = vi.fn().mockReturnValue(res as Response);
res.setHeader = vi.fn().mockReturnValue(res as Response);
res.send = vi.fn().mockReturnValue(res as Response);
return res as Response;
};
@@ -154,6 +194,276 @@ describe('MediaController', () => {
});
});
describe('getTeamLogo', () => {
it('should return generated team logo SVG', async () => {
const teamId = 'team-123';
const svg = '<svg>logo</svg>';
generationService.generateTeamLogo.mockReturnValue(svg);
const res = createMockResponse();
await controller.getTeamLogo(teamId, res);
expect(generationService.generateTeamLogo).toHaveBeenCalledWith(teamId);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/svg+xml; charset=utf-8');
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith(svg);
});
});
describe('getLeagueLogo', () => {
it('should return generated league logo SVG', async () => {
const leagueId = 'league-123';
const svg = '<svg>league-logo</svg>';
generationService.generateLeagueLogo.mockReturnValue(svg);
const res = createMockResponse();
await controller.getLeagueLogo(leagueId, res);
expect(generationService.generateLeagueLogo).toHaveBeenCalledWith(leagueId);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/svg+xml; charset=utf-8');
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith(svg);
});
});
describe('getLeagueCover', () => {
it('should return generated league cover SVG', async () => {
const leagueId = 'league-123';
const svg = '<svg>league-cover</svg>';
generationService.generateLeagueCover.mockReturnValue(svg);
const res = createMockResponse();
await controller.getLeagueCover(leagueId, res);
expect(generationService.generateLeagueCover).toHaveBeenCalledWith(leagueId);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/svg+xml; charset=utf-8');
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith(svg);
});
});
describe('getDriverAvatar', () => {
it('should return generated driver avatar SVG', async () => {
const driverId = 'driver-123';
const svg = '<svg>avatar</svg>';
generationService.generateDriverAvatar.mockReturnValue(svg);
const res = createMockResponse();
await controller.getDriverAvatar(driverId, res);
expect(generationService.generateDriverAvatar).toHaveBeenCalledWith(driverId);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/svg+xml; charset=utf-8');
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith(svg);
});
});
describe('getDefaultMedia', () => {
it('should return PNG with correct cache headers', async () => {
const variant = 'male-default-avatar';
const pngBuffer = Buffer.from([0x89, 0x50, 0x4E, 0x47]); // PNG header
generationService.generateDefaultPNG.mockReturnValue(pngBuffer);
const res = createMockResponse();
await controller.getDefaultMedia(variant, res);
expect(generationService.generateDefaultPNG).toHaveBeenCalledWith(variant);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/png');
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'public, max-age=31536000, immutable');
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith(pngBuffer);
});
it('should handle different variants', async () => {
const variants = ['male-default-avatar', 'female-default-avatar', 'neutral-default-avatar', 'logo'];
for (const variant of variants) {
const pngBuffer = Buffer.from([0x89, 0x50, 0x4E, 0x47]);
generationService.generateDefaultPNG.mockReturnValue(pngBuffer);
const res = createMockResponse();
await controller.getDefaultMedia(variant, res);
expect(generationService.generateDefaultPNG).toHaveBeenCalledWith(variant);
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'public, max-age=31536000, immutable');
}
});
});
describe('getGeneratedMedia', () => {
it('should return team logo SVG with long cache', async () => {
const type = 'team';
const id = '123';
const svg = '<svg>team-logo</svg>';
generationService.generateTeamLogo.mockReturnValue(svg);
const res = createMockResponse();
await controller.getGeneratedMedia(type, id, res);
expect(generationService.generateTeamLogo).toHaveBeenCalledWith(id);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/svg+xml; charset=utf-8');
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'public, max-age=31536000, immutable');
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith(svg);
});
it('should return league logo SVG with long cache', async () => {
const type = 'league';
const id = '456';
const svg = '<svg>league-logo</svg>';
generationService.generateLeagueLogo.mockReturnValue(svg);
const res = createMockResponse();
await controller.getGeneratedMedia(type, id, res);
expect(generationService.generateLeagueLogo).toHaveBeenCalledWith(id);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/svg+xml; charset=utf-8');
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'public, max-age=31536000, immutable');
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith(svg);
});
it('should return driver avatar SVG with long cache', async () => {
const type = 'driver';
const id = '789';
const svg = '<svg>driver-avatar</svg>';
generationService.generateDriverAvatar.mockReturnValue(svg);
const res = createMockResponse();
await controller.getGeneratedMedia(type, id, res);
expect(generationService.generateDriverAvatar).toHaveBeenCalledWith(id);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/svg+xml; charset=utf-8');
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'public, max-age=31536000, immutable');
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith(svg);
});
it('should handle unknown types with fallback', async () => {
const type = 'unknown';
const id = '999';
const svg = '<svg>fallback</svg>';
generationService.generateLeagueLogo.mockReturnValue(svg);
const res = createMockResponse();
await controller.getGeneratedMedia(type, id, res);
expect(generationService.generateLeagueLogo).toHaveBeenCalledWith('unknown-999');
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/svg+xml; charset=utf-8');
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'public, max-age=31536000, immutable');
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith(svg);
});
});
describe('getUploadedMedia', () => {
it('should return uploaded media bytes with correct headers', async () => {
const mediaId = 'media-123';
const pngBuffer = Buffer.from([0x89, 0x50, 0x4E, 0x47]); // PNG header
const mockStorage = {
getBytes: vi.fn().mockResolvedValue(pngBuffer),
getMetadata: vi.fn().mockResolvedValue({ size: 4, contentType: 'image/png' }),
};
const mockService = {
getMedia: vi.fn().mockResolvedValue({ id: mediaId }),
};
const module = await Test.createTestingModule({
controllers: [MediaController],
providers: [
{
provide: MediaService,
useValue: mockService,
},
{
provide: MediaGenerationService,
useValue: {},
},
{
provide: MediaResolverAdapter,
useValue: {},
},
{
provide: LOGGER_TOKEN,
useValue: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
},
{
provide: MEDIA_STORAGE_PORT_TOKEN,
useValue: mockStorage,
},
],
}).compile();
const testController = module.get<MediaController>(MediaController);
const res = createMockResponse();
await testController.getUploadedMedia(mediaId, res);
expect(mockService.getMedia).toHaveBeenCalledWith(mediaId);
expect(mockStorage.getBytes).toHaveBeenCalledWith('uploaded/media-123');
expect(mockStorage.getMetadata).toHaveBeenCalledWith('uploaded/media-123');
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/png');
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'public, max-age=31536000, immutable');
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith(pngBuffer);
});
it('should return 404 when media not found', async () => {
const mediaId = 'media-123';
const mockStorage = {
getBytes: vi.fn(),
getMetadata: vi.fn(),
};
const mockService = {
getMedia: vi.fn().mockResolvedValue(null),
};
const module = await Test.createTestingModule({
controllers: [MediaController],
providers: [
{
provide: MediaService,
useValue: mockService,
},
{
provide: MediaGenerationService,
useValue: {},
},
{
provide: MediaResolverAdapter,
useValue: {},
},
{
provide: LOGGER_TOKEN,
useValue: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
},
{
provide: MEDIA_STORAGE_PORT_TOKEN,
useValue: mockStorage,
},
],
}).compile();
const testController = module.get<MediaController>(MediaController);
const res = createMockResponse();
await testController.getUploadedMedia(mediaId, res);
expect(mockService.getMedia).toHaveBeenCalledWith(mediaId);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'Media not found' });
});
});
describe('getMedia', () => {
it('should return media if found', async () => {
const mediaId = 'media-123';
@@ -208,7 +518,7 @@ describe('MediaController', () => {
});
});
describe('getAvatar', () => {
describe('getAvatarDetails', () => {
it('should return avatar if found', async () => {
const driverId = 'driver-123';
const dto: GetAvatarOutputDTO = {
@@ -218,7 +528,7 @@ describe('MediaController', () => {
const res = createMockResponse();
await controller.getAvatar(driverId, res);
await controller.getAvatarDetails(driverId, res);
expect(service.getAvatar).toHaveBeenCalledWith(driverId);
expect(res.status).toHaveBeenCalledWith(200);
@@ -231,7 +541,7 @@ describe('MediaController', () => {
const res = createMockResponse();
await controller.getAvatar(driverId, res);
await controller.getAvatarDetails(driverId, res);
expect(service.getAvatar).toHaveBeenCalledWith(driverId);
expect(res.status).toHaveBeenCalledWith(404);
@@ -289,6 +599,55 @@ describe('MediaController', () => {
useValue: {
getMedia: vi.fn(async () => ({ id: 'm1' })),
deleteMedia: vi.fn(async () => ({ success: true })),
requestAvatarGeneration: vi.fn(),
uploadMedia: vi.fn(),
getAvatar: vi.fn(),
updateAvatar: vi.fn(),
},
},
{
provide: MediaGenerationService,
useValue: {
generateDriverAvatar: vi.fn(() => '<svg>avatar</svg>'),
generateTeamLogo: vi.fn(() => '<svg>logo</svg>'),
generateLeagueLogo: vi.fn(() => '<svg>league</svg>'),
generateDefaultPNG: vi.fn(() => Buffer.from([0x89, 0x50, 0x4E, 0x47])),
generateLeagueCover: vi.fn(() => '<svg>cover</svg>'),
},
},
{
provide: MediaResolverAdapter,
useValue: {
resolve: vi.fn((ref) => {
if (ref.type === 'system-default') {
return `/media/default/${ref.variant}`;
}
if (ref.type === 'generated') {
return `/media/generated/${ref.generationRequestId}`;
}
if (ref.type === 'uploaded') {
return `/media/uploaded/${ref.mediaId}`;
}
return null;
}),
},
},
{
provide: LOGGER_TOKEN,
useValue: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
},
{
provide: MEDIA_STORAGE_PORT_TOKEN,
useValue: {
uploadMedia: vi.fn(),
deleteMedia: vi.fn(),
getBytes: vi.fn(),
getMetadata: vi.fn(),
},
},
],
@@ -327,5 +686,16 @@ describe('MediaController', () => {
await request(app.getHttpServer()).delete('/media/m1').expect(200);
});
it('allows new public routes without authentication', async () => {
// Test default media route
await request(app.getHttpServer()).get('/media/default/male-default-avatar').expect(200);
// Test generated media route
await request(app.getHttpServer()).get('/media/generated/team/123').expect(200);
// Test debug resolve route
await request(app.getHttpServer()).get('/media/debug/resolve?type=system-default&variant=avatar').expect(200);
});
});
});

View File

@@ -16,149 +16,29 @@ import { UpdateAvatarOutputDTO } from './dtos/UpdateAvatarOutputDTO';
import { ValidateFaceInputDTO } from './dtos/ValidateFaceInputDTO';
import { ValidateFaceOutputDTO } from './dtos/ValidateFaceOutputDTO';
import type { MulterFile } from './types/MulterFile';
import { MediaGenerationService } from '@core/media/domain/services/MediaGenerationService';
import type { Logger } from '@core/shared/application/Logger';
import { MediaReference } from '@core/domain/media/MediaReference';
import { MediaResolverAdapter } from '@adapters/media/MediaResolverAdapter';
import { LOGGER_TOKEN, MEDIA_STORAGE_PORT_TOKEN } from './MediaTokens';
import type { MediaStoragePort } from '@core/media/application/ports/MediaStoragePort';
import path from 'node:path';
import fs from 'node:fs/promises';
type RequestAvatarGenerationInput = RequestAvatarGenerationInputDTO;
type UploadMediaInput = UploadMediaInputDTO;
type UpdateAvatarInput = UpdateAvatarInputDTO;
function hashToHue(input: string): number {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
hash = (hash * 31 + input.charCodeAt(i)) | 0;
}
return Math.abs(hash) % 360;
}
function escapeXml(input: string): string {
return input
.replaceAll('&', '\u0026amp;')
.replaceAll('<', '\u0026lt;')
.replaceAll('>', '\u0026gt;')
.replaceAll('"', '\u0026quot;')
.replaceAll("'", '\u0026apos;');
}
function deriveLeagueLabel(leagueId: string): string {
const digits = leagueId.match(/\d+/)?.[0];
if (digits) return digits.slice(-2);
return leagueId.replaceAll(/[^a-zA-Z]/g, '').slice(0, 2).toUpperCase() || 'GP';
}
function buildLeagueLogoSvg(leagueId: string): string {
const hue = hashToHue(leagueId);
const label = escapeXml(deriveLeagueLabel(leagueId));
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="League logo">
<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="92" height="92" rx="18" fill="url(#g)" stroke="${border}" stroke-width="4"/>
<text x="48" y="56" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial" font-size="34" font-weight="800" text-anchor="middle" fill="white">${label}</text>
</svg>`;
}
function buildLeagueCoverSvg(leagueId: string): string {
const hue = hashToHue(leagueId);
const title = escapeXml(leagueId);
const bg1 = `hsl(${hue} 70% 28%)`;
const bg2 = `hsl(${(hue + 35) % 360} 85% 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="League cover">
<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>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="rgba(255,255,255,0.07)" stroke-width="2"/>
</pattern>
</defs>
<rect width="1200" height="400" fill="url(#bg)"/>
<rect width="1200" height="400" fill="url(#grid)"/>
<circle cx="1020" cy="120" r="180" fill="rgba(255,255,255,0.06)"/>
<circle cx="1080" cy="170" r="120" fill="rgba(255,255,255,0.05)"/>
<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)">GridPilot League</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)">${title}</text>
</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 {
constructor(@Inject(MediaService) private readonly mediaService: MediaService) {}
constructor(
@Inject(MediaService) private readonly mediaService: MediaService,
@Inject(MediaGenerationService) private readonly mediaGenerationService: MediaGenerationService,
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
@Inject(MediaResolverAdapter) private readonly mediaResolver: MediaResolverAdapter,
@Inject(MEDIA_STORAGE_PORT_TOKEN) private readonly mediaStorage: MediaStoragePort,
) {}
@Post('avatar/generate')
@ApiOperation({ summary: 'Request avatar generation' })
@@ -167,11 +47,14 @@ export class MediaController {
@Body() input: RequestAvatarGenerationInput,
@Res() res: Response,
): Promise<void> {
this.logger.debug('[MediaController] Requesting avatar generation', { input });
const dto: RequestAvatarGenerationOutputDTO = await this.mediaService.requestAvatarGeneration(input);
if (dto.success) {
this.logger.info('[MediaController] Avatar generation request successful', { dto });
res.status(HttpStatus.CREATED).json(dto);
} else {
this.logger.warn('[MediaController] Avatar generation request failed', { dto });
res.status(HttpStatus.BAD_REQUEST).json(dto);
}
}
@@ -186,167 +69,306 @@ export class MediaController {
@Body() input: UploadMediaInput,
@Res() res: Response,
): Promise<void> {
this.logger.debug('[MediaController] Uploading media', { filename: file?.originalname, input });
const dto: UploadMediaOutputDTO = await this.mediaService.uploadMedia({ ...input, file });
if (dto.success) {
this.logger.info('[MediaController] Media upload successful', { mediaId: dto.mediaId });
res.status(HttpStatus.CREATED).json(dto);
} else {
this.logger.warn('[MediaController] Media upload failed', { error: dto.error });
res.status(HttpStatus.BAD_REQUEST).json(dto);
}
}
@Public()
@Get('leagues/:leagueId/logo')
@ApiOperation({ summary: 'Get league logo (placeholder)' })
@ApiParam({ name: 'leagueId', description: 'League ID' })
async getLeagueLogo(
@Param('leagueId') leagueId: string,
@Res() res: Response,
): Promise<void> {
const svg = buildLeagueLogoSvg(leagueId);
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('leagues/:leagueId/cover')
@ApiOperation({ summary: 'Get league cover (placeholder)' })
@ApiParam({ name: 'leagueId', description: 'League ID' })
async getLeagueCover(
@Param('leagueId') leagueId: string,
@Res() res: Response,
): Promise<void> {
const svg = buildLeagueCoverSvg(leagueId);
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/logo')
@ApiOperation({ summary: 'Get team logo (placeholder)' })
@ApiOperation({ summary: 'Get team logo (dynamically generated)' })
@ApiParam({ name: 'teamId', description: 'Team ID' })
async getTeamLogo(
@Param('teamId') teamId: string,
@Res() res: Response,
): Promise<void> {
const svg = buildLeagueLogoSvg(teamId);
this.logger.debug('[MediaController] Generating team logo', { teamId });
const svg = this.mediaGenerationService.generateTeamLogo(teamId);
const svgLength = svg.length;
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.status(HttpStatus.OK).send(svg);
this.logger.info('[MediaController] Team logo generated', { teamId, svgLength });
}
@Public()
@Get('team/:teamId/logo')
@ApiOperation({ summary: 'Get team logo (singular path)' })
@ApiParam({ name: 'teamId', description: 'Team ID' })
async getTeamLogoSingular(
@Param('teamId') teamId: string,
@Get('leagues/:leagueId/logo')
@ApiOperation({ summary: 'Get league logo (dynamically generated)' })
@ApiParam({ name: 'leagueId', description: 'League ID' })
async getLeagueLogo(
@Param('leagueId') leagueId: string,
@Res() res: Response,
): Promise<void> {
const svg = buildLeagueLogoSvg(teamId);
this.logger.debug('[MediaController] Generating league logo', { leagueId });
const svg = this.mediaGenerationService.generateLeagueLogo(leagueId);
const svgLength = svg.length;
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.status(HttpStatus.OK).send(svg);
this.logger.info('[MediaController] League logo generated', { leagueId, svgLength });
}
@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,
@Get('leagues/:leagueId/cover')
@ApiOperation({ summary: 'Get league cover (dynamically generated)' })
@ApiParam({ name: 'leagueId', description: 'League ID' })
async getLeagueCover(
@Param('leagueId') leagueId: string,
@Res() res: Response,
): Promise<void> {
const svg = buildLeagueLogoSvg(teamId);
this.logger.debug('[MediaController] Generating league cover', { leagueId });
const svg = this.mediaGenerationService.generateLeagueCover(leagueId);
const svgLength = svg.length;
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.status(HttpStatus.OK).send(svg);
this.logger.info('[MediaController] League cover generated', { leagueId, svgLength });
}
@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)' })
@Get('avatar/:driverId')
@ApiOperation({ summary: 'Get driver avatar (dynamically generated)' })
@ApiParam({ name: 'driverId', description: 'Driver ID' })
async getDriverAvatar(
@Param('driverId') driverId: string,
@Res() res: Response,
): Promise<void> {
const svg = buildDriverAvatarSvg(driverId);
this.logger.debug('[MediaController] Generating driver avatar', { driverId });
const svg = this.mediaGenerationService.generateDriverAvatar(driverId);
const svgLength = svg.length;
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.status(HttpStatus.OK).send(svg);
this.logger.info('[MediaController] Driver avatar generated', { driverId, svgLength });
}
@Public()
@Get('avatar/:driverId')
@ApiOperation({ summary: 'Get driver avatar (alternative path)' })
@ApiParam({ name: 'driverId', description: 'Driver ID' })
async getDriverAvatarAlt(
@Param('driverId') driverId: string,
@Get('default/:variant')
@ApiOperation({ summary: 'Get default media asset (PNG)' })
@ApiParam({ name: 'variant', description: 'Variant name (e.g., male-default-avatar, female-default-avatar, logo)' })
async getDefaultMedia(
@Param('variant') variant: 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);
this.logger.debug('[MediaController] Getting default media', { variant });
// Drivers must use real assets from website public dir.
// Supported:
// - male-default-avatar
// - female-default-avatar
// - neutral-default-avatar
if (
variant === 'male-default-avatar' ||
variant === 'female-default-avatar' ||
variant === 'neutral-default-avatar'
) {
const candidates = [`${variant}.jpg`, `${variant}.jpeg`];
// This needs to work in multiple runtimes:
// - docker dev (cwd often: /app/apps/api) -> ../website
// - local tests (cwd often: repo root) -> apps/website
// Prefer a deterministic directory discovery rather than assuming a single cwd.
const baseDirs = [
path.resolve(process.cwd(), 'apps', 'website', 'public', 'images', 'avatars'),
path.resolve(process.cwd(), '..', 'website', 'public', 'images', 'avatars'),
];
for (const baseDir of baseDirs) {
for (const filename of candidates) {
const abs = path.join(baseDir, filename);
try {
const bytes = await fs.readFile(abs);
res.setHeader('Content-Type', filename.endsWith('.png') ? 'image/png' : 'image/jpeg');
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
res.status(HttpStatus.OK).send(bytes);
this.logger.info('[MediaController] Default avatar served', { variant, filename, baseDir, size: bytes.length });
return;
} catch {
// try next filename/baseDir
}
}
}
this.logger.warn('[MediaController] Default avatar asset not found', { variant, baseDirs, candidates });
res.status(HttpStatus.NOT_FOUND).json({ error: 'Default avatar asset not found' });
return;
}
// Fallback: generated PNG for other defaults
const png = this.mediaGenerationService.generateDefaultPNG(variant);
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
res.status(HttpStatus.OK).send(png);
this.logger.info('[MediaController] Default media generated', { variant, size: png.length });
}
@Public()
@Get('tracks/:trackId/image')
@ApiOperation({ summary: 'Get track image (placeholder)' })
@ApiParam({ name: 'trackId', description: 'Track ID' })
async getTrackImage(
@Param('trackId') trackId: string,
@Get('generated/:type/:id')
@ApiOperation({ summary: 'Get generated media (SVG)' })
@ApiParam({ name: 'type', description: 'Media type (team, league, driver)' })
@ApiParam({ name: 'id', description: 'Entity ID' })
async getGeneratedMedia(
@Param('type') type: string,
@Param('id') id: string,
@Res() res: Response,
): Promise<void> {
const svg = buildTrackImageSvg(trackId);
this.logger.debug('[MediaController] Generating media', { type, id });
let svg: string;
// Route to appropriate generator based on type
if (type === 'team') {
svg = this.mediaGenerationService.generateTeamLogo(id);
} else if (type === 'league') {
svg = this.mediaGenerationService.generateLeagueLogo(id);
} else if (type === 'driver') {
svg = this.mediaGenerationService.generateDriverAvatar(id);
} else {
// Fallback: generate a generic logo
svg = this.mediaGenerationService.generateLeagueLogo(`${type}-${id}`);
}
const svgLength = svg.length;
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
res.status(HttpStatus.OK).send(svg);
this.logger.info('[MediaController] Generated media served', { type, id, svgLength });
}
@Public()
@Get('categories/:categoryId/icon')
@ApiOperation({ summary: 'Get category icon (placeholder)' })
@ApiParam({ name: 'categoryId', description: 'Category ID' })
async getCategoryIcon(
@Param('categoryId') categoryId: string,
@Get('uploaded/:mediaId')
@ApiOperation({ summary: 'Get uploaded media' })
@ApiParam({ name: 'mediaId', description: 'Media ID' })
async getUploadedMedia(
@Param('mediaId') mediaId: 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);
this.logger.debug('[MediaController] Getting uploaded media', { mediaId });
// Look up the media to get the storage key
const media = await this.mediaService.getMedia(mediaId);
if (!media) {
this.logger.warn('[MediaController] Uploaded media not found', { mediaId });
res.status(HttpStatus.NOT_FOUND).json({ error: 'Media not found' });
return;
}
// Get the storage key from the media reference
// The mediaId is used as the storage key
const storageKey = `uploaded/${mediaId}`;
// Get file bytes from storage
const bytes = await this.mediaStorage.getBytes!(storageKey);
if (!bytes) {
this.logger.warn('[MediaController] Uploaded media file not found', { mediaId, storageKey });
res.status(HttpStatus.NOT_FOUND).json({ error: 'Media file not found' });
return;
}
// Get metadata to determine content type
const metadata = await this.mediaStorage.getMetadata!(storageKey);
const contentType = metadata?.contentType || 'application/octet-stream';
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
res.status(HttpStatus.OK).send(bytes);
this.logger.info('[MediaController] Uploaded media served', { mediaId, storageKey, size: bytes.length });
}
@Public()
@Get('sponsors/:sponsorId/logo')
@ApiOperation({ summary: 'Get sponsor logo (placeholder)' })
@ApiParam({ name: 'sponsorId', description: 'Sponsor ID' })
async getSponsorLogo(
@Param('sponsorId') sponsorId: string,
@Get('debug/resolve')
@ApiOperation({ summary: 'Debug media reference resolution' })
@ApiResponse({ status: 200, description: 'Resolution debug info' })
async debugResolve(
@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);
this.logger.debug('[MediaController] Debug resolve requested');
// Parse query parameters
const query = res.req.query;
let ref: MediaReference | null = null;
let refHash: string | null = null;
let resolvedPath: string | null = null;
let resolver: string | null = null;
const notes: string[] = [];
try {
// Try to construct MediaReference from query params
if (query.type === 'system-default' && query.variant) {
const variant = query.variant as 'avatar' | 'logo';
const avatarVariant = query.avatarVariant as 'male' | 'female' | 'neutral' | undefined;
ref = MediaReference.createSystemDefault(variant, avatarVariant);
resolver = 'default';
} else if (query.type === 'generated' && query.generationRequestId) {
ref = MediaReference.createGenerated(query.generationRequestId as string);
resolver = 'generated';
} else if (query.type === 'uploaded' && query.mediaId) {
ref = MediaReference.createUploaded(query.mediaId as string);
resolver = 'uploaded';
} else if (query.ref) {
// Try to parse base64url JSON
try {
const decoded = Buffer.from(query.ref as string, 'base64').toString('utf-8');
const props = JSON.parse(decoded);
ref = MediaReference.fromJSON(props);
resolver = 'auto-detected';
} catch (e) {
notes.push('Failed to parse ref as base64url JSON');
}
} else {
notes.push('No valid query parameters provided');
notes.push('Expected: type, variant, avatarVariant OR generationRequestId OR mediaId OR ref (base64url)');
}
if (ref) {
refHash = ref.hash();
resolvedPath = await this.mediaResolver.resolve(ref);
if (!resolvedPath) {
notes.push('Resolver returned null');
}
}
this.logger.info('[MediaController] Debug resolve completed', {
ref: ref ? ref.toJSON() : null,
refHash,
resolvedPath,
resolver,
notes,
});
res.status(HttpStatus.OK).json({
ref: ref ? ref.toJSON() : null,
refHash,
resolvedPath,
resolver,
notes,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error('[MediaController] Debug resolve failed', error instanceof Error ? error : new Error(String(error)));
res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
error: errorMessage,
notes: ['Internal error during resolution'],
});
}
}
@Public()
@@ -358,11 +380,14 @@ export class MediaController {
@Param('mediaId') mediaId: string,
@Res() res: Response,
): Promise<void> {
this.logger.debug('[MediaController] Getting media details', { mediaId });
const dto: GetMediaOutputDTO | null = await this.mediaService.getMedia(mediaId);
if (dto) {
this.logger.info('[MediaController] Media details found', { mediaId });
res.status(HttpStatus.OK).json(dto);
} else {
this.logger.warn('[MediaController] Media not found', { mediaId });
res.status(HttpStatus.NOT_FOUND).json({ error: 'Media not found' });
}
}
@@ -375,25 +400,30 @@ export class MediaController {
@Param('mediaId') mediaId: string,
@Res() res: Response,
): Promise<void> {
this.logger.debug('[MediaController] Deleting media', { mediaId });
const dto: DeleteMediaOutputDTO = await this.mediaService.deleteMedia(mediaId);
this.logger.info('[MediaController] Media deletion result', { mediaId, success: dto.success });
res.status(HttpStatus.OK).json(dto);
}
@Public()
@Get('avatar/:driverId')
@ApiOperation({ summary: 'Get avatar for driver' })
@Get('avatar/:driverId/details')
@ApiOperation({ summary: 'Get avatar details for driver' })
@ApiParam({ name: 'driverId', description: 'Driver ID' })
@ApiResponse({ status: 200, description: 'Avatar details', type: GetAvatarOutputDTO })
async getAvatar(
async getAvatarDetails(
@Param('driverId') driverId: string,
@Res() res: Response,
): Promise<void> {
this.logger.debug('[MediaController] Getting avatar details', { driverId });
const dto: GetAvatarOutputDTO | null = await this.mediaService.getAvatar(driverId);
if (dto) {
this.logger.info('[MediaController] Avatar details found', { driverId });
res.status(HttpStatus.OK).json(dto);
} else {
this.logger.warn('[MediaController] Avatar not found', { driverId });
res.status(HttpStatus.NOT_FOUND).json({ error: 'Avatar not found' });
}
}
@@ -407,8 +437,10 @@ export class MediaController {
@Body() input: UpdateAvatarInput,
@Res() res: Response,
): Promise<void> {
this.logger.debug('[MediaController] Updating avatar', { driverId });
const dto: UpdateAvatarOutputDTO = await this.mediaService.updateAvatar(driverId, input);
this.logger.info('[MediaController] Avatar update result', { driverId, success: dto.success });
res.status(HttpStatus.OK).json(dto);
}
@@ -419,12 +451,15 @@ export class MediaController {
@Body() input: ValidateFaceInputDTO,
@Res() res: Response,
): Promise<void> {
this.logger.debug('[MediaController] Validating face photo');
const dto: ValidateFaceOutputDTO = await this.mediaService.validateFacePhoto(input);
if (dto.isValid) {
this.logger.info('[MediaController] Face validation passed');
res.status(HttpStatus.OK).json(dto);
} else {
this.logger.warn('[MediaController] Face validation failed', { errorMessage: dto.errorMessage });
res.status(HttpStatus.BAD_REQUEST).json(dto);
}
}
}
}

View File

@@ -59,7 +59,6 @@ export * from './MediaTokens';
import type { FaceValidationResult } from '@core/media/application/ports/FaceValidationPort';
import type { AvatarGenerationResult } from '@core/media/application/ports/AvatarGenerationPort';
import type { UploadResult } from '@core/media/application/ports/MediaStoragePort';
// External adapters (ports) - these remain mock implementations
class MockFaceValidationAdapter implements FaceValidationPort {
@@ -86,17 +85,6 @@ class MockAvatarGenerationAdapter implements AvatarGenerationPort {
}
}
class MockMediaStorageAdapter implements MediaStoragePort {
async uploadMedia(): Promise<UploadResult> {
return {
success: true,
url: 'https://cdn.example.com/media/mock-file.png',
filename: 'mock-file.png',
};
}
async deleteMedia(): Promise<void> {}
}
class MockLogger implements Logger {
debug(): void {}
info(): void {}
@@ -104,7 +92,16 @@ class MockLogger implements Logger {
error(): void {}
}
import { MediaGenerationService } from '@core/media/domain/services/MediaGenerationService';
import { MediaResolverAdapter } from '@adapters/media/MediaResolverAdapter';
import { FileSystemMediaStorageAdapter } from '@adapters/media/ports/FileSystemMediaStorageAdapter';
export const MediaProviders: Provider[] = [
MediaGenerationService,
{
provide: MediaResolverAdapter,
useFactory: () => new MediaResolverAdapter({}),
},
RequestAvatarGenerationPresenter,
UploadMediaPresenter,
GetMediaPresenter,
@@ -121,7 +118,9 @@ export const MediaProviders: Provider[] = [
},
{
provide: MEDIA_STORAGE_PORT_TOKEN,
useClass: MockMediaStorageAdapter,
useFactory: () => new FileSystemMediaStorageAdapter({
baseDir: process.env.MEDIA_STORAGE_DIR || '/data/media',
}),
},
{
provide: LOGGER_TOKEN,

View File

@@ -166,6 +166,16 @@ export class MediaService {
async updateAvatar(driverId: string, input: UpdateAvatarInput): Promise<UpdateAvatarOutputDTO> {
this.logger.debug(`[MediaService] Updating avatar for driver: ${driverId}`);
// Handle null avatarUrl - this would mean removing the avatar
if (input.avatarUrl === null) {
// For now, we'll treat null as an error since the use case requires a URL
// In a complete implementation, this would trigger avatar removal
return {
success: false,
error: 'Avatar URL cannot be null',
};
}
const result = await this.updateAvatarUseCase.execute({
driverId,
mediaUrl: input.avatarUrl,

View File

@@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
export class GetAvatarOutputDTO {
@ApiProperty()
@ApiProperty({ nullable: true })
@IsString()
avatarUrl: string = '';
avatarUrl: string | null = null;
}

View File

@@ -6,7 +6,7 @@ export class UpdateAvatarInputDTO {
@IsString()
driverId: string = '';
@ApiProperty()
@ApiProperty({ nullable: true })
@IsString()
avatarUrl: string = '';
avatarUrl: string | null = null;
}

View File

@@ -14,9 +14,9 @@ export class DashboardDriverSummaryDTO {
@IsString()
country!: string;
@ApiProperty()
@ApiProperty({ nullable: true })
@IsString()
avatarUrl!: string;
avatarUrl!: string | null;
@ApiProperty({ nullable: true })
@IsOptional()

View File

@@ -14,7 +14,7 @@ export class DashboardFriendSummaryDTO {
@IsString()
country!: string;
@ApiProperty()
@ApiProperty({ nullable: true })
@IsString()
avatarUrl!: string;
avatarUrl!: string | null;
}

View File

@@ -15,10 +15,10 @@ export class DriverSummaryDTO {
@IsString()
country?: string;
@ApiProperty({ required: false })
@ApiProperty({ nullable: true })
@IsOptional()
@IsString()
avatarUrl?: string;
avatarUrl!: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()

View File

@@ -14,9 +14,9 @@ export class RaceDetailEntryDTO {
@IsString()
country!: string;
@ApiProperty()
@ApiProperty({ nullable: true })
@IsString()
avatarUrl!: string;
avatarUrl!: string | null;
@ApiProperty({ nullable: true })
rating!: number | null;

View File

@@ -10,9 +10,9 @@ export class RaceResultDTO {
@IsString()
driverName!: string;
@ApiProperty()
@ApiProperty({ nullable: true })
@IsString()
avatarUrl!: string;
avatarUrl!: string | null;
@ApiProperty()
@IsNumber()

View File

@@ -7,6 +7,7 @@ export const RACE_REPOSITORY_TOKEN = 'IRaceRepository';
export const SPONSORSHIP_PRICING_REPOSITORY_TOKEN = 'ISponsorshipPricingRepository';
export const SPONSORSHIP_REQUEST_REPOSITORY_TOKEN = 'ISponsorshipRequestRepository';
export const LOGGER_TOKEN = 'Logger';
export const MEDIA_RESOLVER_TOKEN = 'MediaResolverPort';
// Presenter tokens
export const GET_ENTITY_SPONSORSHIP_PRICING_PRESENTER_TOKEN = 'GetEntitySponsorshipPricingPresenter';

View File

@@ -10,8 +10,8 @@ export class SponsorDTO {
@ApiProperty({ required: false })
contactEmail?: string;
@ApiProperty({ required: false })
logoUrl?: string;
@ApiProperty({ nullable: true })
logoUrl!: string | null;
@ApiProperty({ required: false })
websiteUrl?: string;

View File

@@ -26,10 +26,10 @@ export class SponsorProfileDTO {
@IsString()
description: string = '';
@ApiProperty({ required: false })
@ApiProperty({ nullable: true })
@IsOptional()
@IsString()
logoUrl?: string;
logoUrl!: string | null;
@ApiProperty()
@IsString()

View File

@@ -1,6 +1,6 @@
import { Provider } from '@nestjs/common';
import { IMAGE_SERVICE_TOKEN, LOGGER_TOKEN, MEDIA_REPOSITORY_TOKEN } from './TeamTokens';
import { IMAGE_SERVICE_TOKEN, LOGGER_TOKEN, MEDIA_REPOSITORY_TOKEN, MEDIA_RESOLVER_TOKEN } from './TeamTokens';
export {
TEAM_REPOSITORY_TOKEN,
@@ -9,15 +9,18 @@ export {
IMAGE_SERVICE_TOKEN,
LOGGER_TOKEN,
MEDIA_REPOSITORY_TOKEN,
MEDIA_RESOLVER_TOKEN,
} from './TeamTokens';
// Import core interfaces
import type { Logger } from '@core/shared/application/Logger';
import type { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
// Import concrete in-memory implementations
// Import concrete implementations
import { InMemoryImageServiceAdapter } from '@adapters/media/ports/InMemoryImageServiceAdapter';
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
import { InMemoryMediaRepository } from '@adapters/racing/persistence/media/InMemoryMediaRepository';
import { MediaResolverAdapter } from '@adapters/media/MediaResolverAdapter';
// Import presenters
import { AllTeamsPresenter } from './presenters/AllTeamsPresenter';
@@ -34,11 +37,36 @@ export const TeamProviders: Provider[] = [
},
{
provide: MEDIA_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryMediaRepository(logger),
useFactory: (logger: Logger) => {
const mediaRepo = new InMemoryMediaRepository(logger);
// Override getTeamLogo to provide fallback URLs
const originalGetTeamLogo = mediaRepo.getTeamLogo.bind(mediaRepo);
mediaRepo.getTeamLogo = async (teamId: string): Promise<string | null> => {
const logo = await originalGetTeamLogo(teamId);
if (logo) return logo;
// Fallback: generate deterministic team logo URL
// Use API port (3001) for media generation
const baseUrl = process.env.NODE_ENV === 'development' ? 'http://localhost:3001' : 'https://api.gridpilot.io';
return `${baseUrl}/media/teams/${teamId}/logo`;
};
return mediaRepo;
},
inject: [LOGGER_TOKEN],
},
{
provide: AllTeamsPresenter,
useFactory: () => new AllTeamsPresenter(),
provide: MEDIA_RESOLVER_TOKEN,
useFactory: () => new MediaResolverAdapter({}),
},
];
{
provide: AllTeamsPresenter,
useFactory: (mediaResolver: MediaResolverPort) => {
const presenter = new AllTeamsPresenter();
presenter.setMediaResolver(mediaResolver);
return presenter;
},
inject: [MEDIA_RESOLVER_TOKEN],
},
];

View File

@@ -23,6 +23,9 @@ type TeamEntityStub = {
ownerId: ValueObjectStub;
leagues: ValueObjectStub[];
createdAt: { toDate(): Date };
logoRef: any;
category: string | undefined;
isRecruiting: boolean;
update: Mock;
};
@@ -43,6 +46,9 @@ describe('TeamService', () => {
ownerId: makeValueObject('owner-1'),
leagues: [makeValueObject('league-1')],
createdAt: { toDate: () => new Date('2023-01-01T00:00:00.000Z') },
logoRef: { type: 'system-default', variant: 'logo' },
category: undefined,
isRecruiting: false,
};
const team: TeamEntityStub = {
@@ -95,7 +101,7 @@ describe('TeamService', () => {
countByTeamId: vi.fn(),
getActiveMembershipForDriver: vi.fn(),
getMembership: vi.fn(),
getTeamMembers: vi.fn(),
getTeamMembers: vi.fn().mockResolvedValue([]),
getJoinRequests: vi.fn(),
saveMembership: vi.fn(),
};
@@ -112,28 +118,38 @@ describe('TeamService', () => {
} as unknown as Logger;
const teamStatsRepository = {
getTeamStats: vi.fn(),
getTeamStats: vi.fn().mockResolvedValue(undefined),
saveTeamStats: vi.fn(),
getAllStats: vi.fn(),
clear: vi.fn(),
};
const mediaRepository = {
getTeamAvatar: vi.fn(),
saveTeamAvatar: vi.fn(),
getDriverAvatar: vi.fn(),
saveDriverAvatar: vi.fn(),
};
const resultRepository = {
findAll: vi.fn(),
findAll: vi.fn().mockResolvedValue([]),
};
// Mock presenter that stores result synchronously
const allTeamsPresenter = {
reset: vi.fn(),
present: vi.fn(),
getResponseModel: vi.fn(() => ({ teams: [], totalCount: 0 })),
present: vi.fn((result: any) => {
// Store immediately and synchronously
allTeamsPresenter.responseModel = {
teams: result.teams.map((t: any) => ({
id: t.id,
name: t.name,
tag: t.tag,
description: t.description,
memberCount: t.memberCount,
leagues: t.leagues,
logoUrl: t.logoUrl ?? null,
})),
totalCount: result.totalCount,
};
}),
getResponseModel: vi.fn(() => allTeamsPresenter.responseModel || { teams: [], totalCount: 0 }),
responseModel: { teams: [], totalCount: 0 },
setMediaResolver: vi.fn(),
setBaseUrl: vi.fn(),
};
service = new TeamService(
@@ -142,9 +158,8 @@ describe('TeamService', () => {
driverRepository as unknown as never,
logger,
teamStatsRepository as unknown as never,
mediaRepository as unknown as never,
resultRepository as unknown as never,
allTeamsPresenter as unknown as never
allTeamsPresenter as any
);
});
@@ -152,7 +167,9 @@ describe('TeamService', () => {
teamRepository.findAll.mockResolvedValue([makeTeam()]);
membershipRepository.countByTeamId.mockResolvedValue(3);
await expect(service.getAll()).resolves.toEqual({
const result = await service.getAll();
await expect(result).toEqual({
teams: [
{
id: 'team-1',
@@ -161,6 +178,7 @@ describe('TeamService', () => {
description: 'Desc',
memberCount: 3,
leagues: ['league-1'],
logoUrl: null,
},
],
totalCount: 1,
@@ -200,6 +218,8 @@ describe('TeamService', () => {
description: 'Desc',
ownerId: 'owner-1',
leagues: ['league-1'],
category: undefined,
isRecruiting: false,
createdAt: '2023-01-01T00:00:00.000Z',
},
membership: {
@@ -503,6 +523,8 @@ describe('TeamService', () => {
description: 'Desc',
ownerId: 'owner-1',
leagues: ['league-1'],
category: undefined,
isRecruiting: false,
createdAt: '2023-01-01T00:00:00.000Z',
},
membership: {

View File

@@ -37,9 +37,8 @@ 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, RESULT_REPOSITORY_TOKEN } from './TeamTokens';
import { TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN, TEAM_STATS_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()
@@ -50,7 +49,6 @@ export class TeamService {
@Inject(DRIVER_REPOSITORY_TOKEN) private readonly driverRepository: IDriverRepository,
@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,
) {}
@@ -62,7 +60,6 @@ export class TeamService {
this.teamRepository,
this.membershipRepository,
this.teamStatsRepository,
this.mediaRepository,
this.resultRepository,
this.logger,
this.allTeamsPresenter

View File

@@ -5,4 +5,5 @@ 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_RESOLVER_TOKEN = 'MediaResolverPort';
export const RESULT_REPOSITORY_TOKEN = 'IResultRepository';

View File

@@ -145,8 +145,8 @@ export class TeamMemberViewModel {
@ApiProperty()
isActive!: boolean;
@ApiProperty()
avatarUrl!: string;
@ApiProperty({ nullable: true })
avatarUrl!: string | null;
}
export class TeamMembersViewModel {
@@ -185,8 +185,8 @@ export class TeamJoinRequestViewModel {
@ApiProperty()
requestedAt!: string;
@ApiProperty()
avatarUrl!: string;
@ApiProperty({ nullable: true })
avatarUrl!: string | null;
}
export class TeamJoinRequestsViewModel {
@@ -339,4 +339,4 @@ export class TeamDTO {
@IsOptional()
@IsString()
createdAt?: string;
}
}

View File

@@ -13,13 +13,12 @@ export class TeamJoinRequestDTO {
@ApiProperty()
teamId!: string;
@ApiProperty({ enum: ['pending', 'approved', 'rejected'] })
@ApiProperty()
status!: 'pending' | 'approved' | 'rejected';
@ApiProperty()
requestedAt!: string;
@ApiProperty()
avatarUrl!: string;
}
@ApiProperty({ nullable: true })
avatarUrl!: string | null;
}

View File

@@ -40,13 +40,12 @@ export class TeamListItemDTO {
@ApiProperty({ required: false })
category?: string | undefined;
@ApiProperty({ required: false })
logoUrl?: string;
@ApiProperty({ nullable: true })
logoUrl: string | null = null;
@ApiProperty({ required: false })
rating?: number;
@ApiProperty()
isRecruiting!: boolean;
}
}

View File

@@ -7,7 +7,7 @@ export class TeamMemberDTO {
@ApiProperty()
driverName!: string;
@ApiProperty({ enum: ['owner', 'manager', 'member'] })
@ApiProperty()
role!: 'owner' | 'manager' | 'member';
@ApiProperty()
@@ -16,7 +16,6 @@ export class TeamMemberDTO {
@ApiProperty()
isActive!: boolean;
@ApiProperty()
avatarUrl!: string;
}
@ApiProperty({ nullable: true })
avatarUrl!: string | null;
}

View File

@@ -2,36 +2,53 @@ import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPo
import type { GetAllTeamsResult } from '@core/racing/application/use-cases/GetAllTeamsUseCase';
import { GetAllTeamsOutputDTO } from '../dtos/GetAllTeamsOutputDTO';
import { TeamListItemDTO } from '../dtos/TeamListItemDTO';
import type { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
import { MediaReference } from '@core/domain/media/MediaReference';
export class AllTeamsPresenter implements UseCaseOutputPort<GetAllTeamsResult> {
private model: GetAllTeamsOutputDTO | null = null;
private mediaResolver?: MediaResolverPort;
setMediaResolver(resolver: MediaResolverPort): void {
this.mediaResolver = resolver;
}
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;
});
async present(result: GetAllTeamsResult): Promise<void> {
const teams: TeamListItemDTO[] = await Promise.all(
result.teams.map(async (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 ?? [];
// Resolve logo URL using MediaResolverPort if available
if (this.mediaResolver && team.logoRef) {
const ref = team.logoRef instanceof MediaReference ? team.logoRef : MediaReference.fromJSON(team.logoRef);
dto.logoUrl = await this.mediaResolver.resolve(ref);
} else {
// Fallback to existing logoUrl or null
dto.logoUrl = team.logoUrl ?? null;
}
dto.rating = team.rating ?? 0;
dto.category = team.category;
dto.isRecruiting = team.isRecruiting;
return dto;
})
);
this.model = {
teams,

View File

@@ -22,6 +22,7 @@ interface OpenAPISchema {
enum?: string[];
nullable?: boolean;
description?: string;
default?: unknown;
}
interface OpenAPISpec {
@@ -367,5 +368,41 @@ describe('API Contract Validation', () => {
}
}
});
it('should have no empty string defaults for avatar/logo URLs', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schemas = spec.components.schemas;
// Check DTOs that should use URL|null pattern
const mediaRelatedDTOs = [
'GetAvatarOutputDTO',
'UpdateAvatarInputDTO',
'DashboardDriverSummaryDTO',
'DriverProfileDriverSummaryDTO',
'DriverLeaderboardItemDTO',
'TeamListItemDTO',
'LeagueSummaryDTO',
'SponsorDTO',
];
for (const dtoName of mediaRelatedDTOs) {
const schema = schemas[dtoName];
if (!schema || !schema.properties) continue;
// Check for avatarUrl, logoUrl properties
for (const [propName, propSchema] of Object.entries(schema.properties)) {
if (propName === 'avatarUrl' || propName === 'logoUrl') {
// Should be string type, nullable (no empty string defaults)
expect(propSchema.type).toBe('string');
expect(propSchema.nullable).toBe(true);
// Should not have default value of empty string
if (propSchema.default !== undefined) {
expect(propSchema.default).not.toBe('');
}
}
}
}
});
});
});

View File

@@ -1,16 +0,0 @@
export const runtime = 'nodejs';
const ONE_BY_ONE_PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO0pS0kAAAAASUVORK5CYII=';
export async function GET(): Promise<Response> {
const body = Buffer.from(ONE_BY_ONE_PNG_BASE64, 'base64');
return new Response(body, {
status: 200,
headers: {
'content-type': 'image/png',
'cache-control': 'public, max-age=60',
},
});
}

View File

@@ -38,6 +38,7 @@ import Card from '@/components/ui/Card';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import { useServices } from '@/lib/services/ServiceProvider';
import { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel';
import { mediaConfig } from '@/lib/config/mediaConfig';
// ============================================================================
// TYPES
@@ -462,7 +463,7 @@ export default function DriverDetailPage() {
<div className="w-28 h-28 md:w-36 md:h-36 rounded-2xl bg-gradient-to-br from-primary-blue to-purple-600 p-1 shadow-xl shadow-primary-blue/20">
<div className="w-full h-full rounded-xl overflow-hidden bg-iron-gray">
<Image
src={driver.avatarUrl}
src={driver.avatarUrl || mediaConfig.avatars.defaultFallback}
alt={driver.name}
width={144}
height={144}
@@ -851,7 +852,7 @@ export default function DriverDetailPage() {
>
<div className="w-8 h-8 rounded-full overflow-hidden bg-gradient-to-br from-primary-blue to-purple-600">
<Image
src={friend.avatarUrl || '/default-avatar.png'}
src={friend.avatarUrl || mediaConfig.avatars.defaultFallback}
alt={friend.name}
width={32}
height={32}

View File

@@ -22,6 +22,7 @@ import Card from '@/components/ui/Card';
import Heading from '@/components/ui/Heading';
import { useDriverLeaderboard } from '@/hooks/useDriverService';
import Image from 'next/image';
import { mediaConfig } from '@/lib/config/mediaConfig';
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
@@ -133,7 +134,7 @@ function FeaturedDriverCard({ driver, position, onClick }: FeaturedDriverCardPro
{/* Avatar & Name */}
<div className="flex items-center gap-4 mb-4">
<div className="relative w-16 h-16 rounded-full overflow-hidden border-2 border-charcoal-outline group-hover:border-primary-blue transition-colors">
<Image src={driver.avatarUrl || '/avatars/default.png'} alt={driver.name} fill className="object-cover" />
<Image src={driver.avatarUrl || mediaConfig.avatars.defaultFallback} alt={driver.name} fill className="object-cover" />
</div>
<div>
<h3 className="text-lg font-semibold text-white group-hover:text-primary-blue transition-colors">
@@ -362,7 +363,7 @@ function LeaderboardPreview({ drivers, onDriverClick }: LeaderboardPreviewProps)
{/* Avatar */}
<div className="relative w-9 h-9 rounded-full overflow-hidden border-2 border-charcoal-outline">
<Image src={driver.avatarUrl || '/avatars/default.png'} alt={driver.name} fill className="object-cover" />
<Image src={driver.avatarUrl || mediaConfig.avatars.defaultFallback} alt={driver.name} fill className="object-cover" />
</div>
{/* Info */}
@@ -436,7 +437,7 @@ function RecentActivity({ drivers, onDriverClick }: RecentActivityProps) {
className="p-3 rounded-xl bg-iron-gray/40 border border-charcoal-outline hover:border-performance-green/40 transition-all group text-center"
>
<div className="relative w-12 h-12 mx-auto rounded-full overflow-hidden border-2 border-charcoal-outline mb-2">
<Image src={driver.avatarUrl || '/avatars/default.png'} alt={driver.name} fill className="object-cover" />
<Image src={driver.avatarUrl || mediaConfig.avatars.defaultFallback} alt={driver.name} fill className="object-cover" />
<div className="absolute bottom-0 right-0 w-3 h-3 rounded-full bg-performance-green border-2 border-iron-gray" />
</div>
<p className="text-sm font-medium text-white truncate group-hover:text-performance-green transition-colors">

View File

@@ -30,7 +30,7 @@ export default function LeagueStandingsPage() {
try {
const vm = await leagueService.getLeagueStandings(leagueId, currentDriverId);
setStandings(vm.standings);
setDrivers(vm.drivers.map((d) => new DriverViewModel(d)));
setDrivers(vm.drivers.map((d) => new DriverViewModel({ ...d, avatarUrl: (d as any).avatarUrl ?? null })));
setMemberships(vm.memberships);
// Check if current user is admin

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(
request: NextRequest,
{ params }: { params: { driverId: string } }
) {
const { driverId } = params;
// In test environment, proxy to the mock API
const apiBaseUrl = process.env.API_BASE_URL || 'http://localhost:3000';
try {
const response = await fetch(`${apiBaseUrl}/media/avatar/${driverId}`, {
method: 'GET',
headers: {
'Content-Type': 'image/png',
},
});
if (!response.ok) {
// Return a fallback image or 404
return new NextResponse(null, { status: 404 });
}
const buffer = await response.arrayBuffer();
return new NextResponse(buffer, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=3600',
},
});
} catch (error) {
console.error('Error fetching avatar:', error);
return new NextResponse(null, { status: 500 });
}
}

View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(
request: NextRequest,
{ params }: { params: { categoryId: string } }
) {
const { categoryId } = params;
const apiBaseUrl = process.env.API_BASE_URL || 'http://localhost:3000';
try {
const response = await fetch(`${apiBaseUrl}/media/categories/${categoryId}/icon`, {
method: 'GET',
});
if (!response.ok) {
return new NextResponse(null, { status: 404 });
}
const buffer = await response.arrayBuffer();
return new NextResponse(buffer, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=3600',
},
});
} catch (error) {
console.error('Error fetching category icon:', error);
return new NextResponse(null, { status: 500 });
}
}

View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(
request: NextRequest,
{ params }: { params: { leagueId: string } }
) {
const { leagueId } = params;
const apiBaseUrl = process.env.API_BASE_URL || 'http://localhost:3000';
try {
const response = await fetch(`${apiBaseUrl}/media/leagues/${leagueId}/cover`, {
method: 'GET',
});
if (!response.ok) {
return new NextResponse(null, { status: 404 });
}
const buffer = await response.arrayBuffer();
return new NextResponse(buffer, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=3600',
},
});
} catch (error) {
console.error('Error fetching league cover:', error);
return new NextResponse(null, { status: 500 });
}
}

View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(
request: NextRequest,
{ params }: { params: { leagueId: string } }
) {
const { leagueId } = params;
const apiBaseUrl = process.env.API_BASE_URL || 'http://localhost:3000';
try {
const response = await fetch(`${apiBaseUrl}/media/leagues/${leagueId}/logo`, {
method: 'GET',
});
if (!response.ok) {
return new NextResponse(null, { status: 404 });
}
const buffer = await response.arrayBuffer();
return new NextResponse(buffer, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=3600',
},
});
} catch (error) {
console.error('Error fetching league logo:', error);
return new NextResponse(null, { status: 500 });
}
}

View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(
request: NextRequest,
{ params }: { params: { sponsorId: string } }
) {
const { sponsorId } = params;
const apiBaseUrl = process.env.API_BASE_URL || 'http://localhost:3000';
try {
const response = await fetch(`${apiBaseUrl}/media/sponsors/${sponsorId}/logo`, {
method: 'GET',
});
if (!response.ok) {
return new NextResponse(null, { status: 404 });
}
const buffer = await response.arrayBuffer();
return new NextResponse(buffer, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=3600',
},
});
} catch (error) {
console.error('Error fetching sponsor logo:', error);
return new NextResponse(null, { status: 500 });
}
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(
request: NextRequest,
{ params }: { params: { teamId: string } }
) {
const { teamId } = params;
// In test environment, proxy to the mock API
// In production, this would fetch from the actual API
const apiBaseUrl = process.env.API_BASE_URL || 'http://localhost:3000';
try {
const response = await fetch(`${apiBaseUrl}/media/teams/${teamId}/logo`, {
method: 'GET',
headers: {
'Content-Type': 'image/png',
},
});
if (!response.ok) {
// Return a fallback image or 404
return new NextResponse(null, { status: 404 });
}
const buffer = await response.arrayBuffer();
return new NextResponse(buffer, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=3600',
},
});
} catch (error) {
console.error('Error fetching team logo:', error);
return new NextResponse(null, { status: 500 });
}
}

View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(
request: NextRequest,
{ params }: { params: { trackId: string } }
) {
const { trackId } = params;
const apiBaseUrl = process.env.API_BASE_URL || 'http://localhost:3000';
try {
const response = await fetch(`${apiBaseUrl}/media/tracks/${trackId}/image`, {
method: 'GET',
});
if (!response.ok) {
return new NextResponse(null, { status: 404 });
}
const buffer = await response.arrayBuffer();
return new NextResponse(buffer, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=3600',
},
});
} catch (error) {
console.error('Error fetching track image:', error);
return new NextResponse(null, { status: 500 });
}
}

View File

@@ -1,4 +1,5 @@
import { redirect } from 'next/navigation';
import Image from 'next/image';
import { getAppMode } from '@/lib/mode';
import Hero from '@/components/landing/Hero';
@@ -16,6 +17,7 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { ServiceFactory } from '@/lib/services/ServiceFactory';
import { getMediaUrl } from '@/lib/utilities/media';
export default async function HomePage() {
const baseUrl = getWebsiteApiBaseUrl();
@@ -299,8 +301,14 @@ export default async function HomePage() {
<ul className="space-y-3 text-sm">
{teams.slice(0, 4).map(team => (
<li key={team.id} className="flex items-start gap-3">
<div className="w-10 h-10 rounded-md bg-charcoal-outline flex items-center justify-center text-xs font-semibold text-white">
{team.tag}
<div className="w-10 h-10 rounded-md bg-charcoal-outline flex items-center justify-center overflow-hidden border border-charcoal-outline">
<Image
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name}
width={40}
height={40}
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-white truncate">{team.name}</p>

View File

@@ -13,6 +13,7 @@ import type {
DriverProfileSocialHandleViewModel,
DriverProfileViewModel
} from '@/lib/view-models/DriverProfileViewModel';
import { getMediaUrl } from '@/lib/utilities/media';
import {
Activity,
Award,
@@ -406,7 +407,7 @@ export default function ProfilePage() {
<div className="w-28 h-28 md:w-36 md:h-36 rounded-2xl bg-gradient-to-br from-primary-blue to-purple-600 p-1 shadow-xl shadow-primary-blue/20">
<div className="w-full h-full rounded-xl overflow-hidden bg-iron-gray">
<Image
src={mediaService.getDriverAvatar(currentDriver.id)}
src={getMediaUrl('driver-avatar', currentDriver.id)}
alt={currentDriver.name}
width={144}
height={144}
@@ -888,7 +889,7 @@ export default function ProfilePage() {
>
<div className="w-8 h-8 rounded-full overflow-hidden bg-gradient-to-br from-primary-blue to-purple-600">
<Image
src={mediaService.getDriverAvatar(friend.id)}
src={getMediaUrl('driver-avatar', friend.id)}
alt={friend.name}
width={32}
height={32}

View File

@@ -16,6 +16,8 @@ import StatItem from '@/components/teams/StatItem';
import { useServices } from '@/lib/services/ServiceProvider';
import { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel';
import { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
import { getMediaUrl } from '@/lib/utilities/media';
import PlaceholderImage from '@/components/ui/PlaceholderImage';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
@@ -191,7 +193,7 @@ export default function TeamDetailPage() {
<div className="flex items-start gap-6">
<div className="w-24 h-24 bg-charcoal-outline rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden">
<Image
src={mediaService.getTeamLogo(team.id)}
src={getMediaUrl('team-logo', team.id)}
alt={team.name}
width={96}
height={96}

View File

@@ -2,6 +2,7 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import {
Users,
Trophy,
@@ -25,6 +26,7 @@ import Heading from '@/components/ui/Heading';
import TopThreePodium from '@/components/teams/TopThreePodium';
import { useAllTeams } from '@/hooks/useTeamService';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import { getMediaUrl } from '@/lib/utilities/media';
// ============================================================================
// TYPES
@@ -407,8 +409,14 @@ export default function TeamLeaderboardPage() {
{/* Team Info */}
<div className="col-span-4 lg:col-span-5 flex items-center gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${levelConfig?.bgColor} border ${levelConfig?.borderColor}`}>
<LevelIcon className={`w-5 h-5 ${levelConfig?.color}`} />
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-charcoal-outline border border-charcoal-outline overflow-hidden">
<Image
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name}
width={40}
height={40}
className="w-full h-full object-cover"
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-white font-semibold truncate group-hover:text-purple-400 transition-colors">

View File

@@ -33,6 +33,7 @@ export default function DriverCard(props: DriverCardProps) {
const driverViewModel = new DriverViewModel({
id,
name,
avatarUrl: null,
});
return (

View File

@@ -1,5 +1,6 @@
import Link from 'next/link';
import Image from 'next/image';
import PlaceholderImage from '@/components/ui/PlaceholderImage';
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
export interface DriverIdentityProps {
@@ -21,8 +22,8 @@ export default function DriverIdentity(props: DriverIdentityProps) {
const metaTextClasses = 'text-xs md:text-sm text-gray-400';
// Use provided avatar URL or fallback to default avatar path
const avatarUrl = driver.avatarUrl || `/api/media/avatar/${driver.id}`;
// Use provided avatar URL or show placeholder if null
const avatarUrl = driver.avatarUrl;
const content = (
<div className="flex items-center gap-3 md:gap-4 flex-1 min-w-0">
@@ -30,13 +31,17 @@ export default function DriverIdentity(props: DriverIdentityProps) {
className={`rounded-full bg-primary-blue/20 overflow-hidden flex items-center justify-center shrink-0`}
style={{ width: avatarSize, height: avatarSize }}
>
<Image
src={avatarUrl}
alt={driver.name}
width={avatarSize}
height={avatarSize}
className="w-full h-full object-cover"
/>
{avatarUrl ? (
<Image
src={avatarUrl}
alt={driver.name}
width={avatarSize}
height={avatarSize}
className="w-full h-full object-cover"
/>
) : (
<PlaceholderImage size={avatarSize} />
)}
</div>
<div className="flex-1 min-w-0">

View File

@@ -1,8 +1,10 @@
import React from 'react';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import { Users, Crown, Shield, ChevronRight } from 'lucide-react';
import Button from '@/components/ui/Button';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import { getMediaUrl } from '@/lib/utilities/media';
interface TeamLeaderboardPreviewProps {
teams: TeamSummaryViewModel[];
@@ -82,9 +84,15 @@ export default function TeamLeaderboardPreview({ teams, onTeamClick }: TeamLeade
{position <= 3 ? <Crown className="w-3.5 h-3.5" /> : position}
</div>
{/* Team Icon */}
<div className={`flex h-9 w-9 items-center justify-center rounded-lg ${levelConfig?.bgColor} border ${levelConfig?.borderColor}`}>
<LevelIcon className={`w-4 h-4 ${levelConfig?.color}`} />
{/* Team Logo */}
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-charcoal-outline border border-charcoal-outline overflow-hidden">
<Image
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name}
width={36}
height={36}
className="w-full h-full object-cover"
/>
</div>
{/* Info */}

View File

@@ -14,7 +14,8 @@ import {
} from 'lucide-react';
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
import { getLeagueCoverClasses } from '@/lib/leagueCovers';
import { useServices } from '@/lib/services/ServiceProvider';
import PlaceholderImage from '@/components/ui/PlaceholderImage';
import { getMediaUrl } from '@/lib/utilities/media';
interface LeagueCardProps {
league: LeagueSummaryViewModel;
@@ -114,9 +115,8 @@ function isNewLeague(createdAt: string | Date): boolean {
}
export default function LeagueCard({ league, onClick }: LeagueCardProps) {
const { mediaService } = useServices();
const coverUrl = mediaService.getLeagueCover(league.id);
const logoUrl = mediaService.getLeagueLogo(league.id);
const coverUrl = getMediaUrl('league-cover', league.id);
const logoUrl = league.logoUrl;
const ChampionshipIcon = getChampionshipIcon(league.scoring?.primaryChampionshipType);
const championshipLabel = getChampionshipLabel(league.scoring?.primaryChampionshipType);
@@ -190,15 +190,19 @@ export default function LeagueCard({ league, onClick }: LeagueCardProps) {
{/* Logo */}
<div className="absolute left-4 -bottom-6 z-10">
<div className="w-12 h-12 rounded-lg overflow-hidden border-2 border-iron-gray bg-deep-graphite shadow-xl">
<img
src={logoUrl}
alt={`${league.name} logo`}
width={48}
height={48}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
{logoUrl ? (
<img
src={logoUrl}
alt={`${league.name} logo`}
width={48}
height={48}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
) : (
<PlaceholderImage size={48} />
)}
</div>
</div>
</div>

View File

@@ -1,7 +1,7 @@
'use client';
import MembershipStatus from '@/components/leagues/MembershipStatus';
import { useServices } from '@/lib/services/ServiceProvider';
import { getMediaUrl } from '@/lib/utilities/media';
import Image from 'next/image';
@@ -28,8 +28,7 @@ export default function LeagueHeader({
ownerId,
mainSponsor,
}: LeagueHeaderProps) {
const { mediaService } = useServices();
const logoUrl = mediaService.getLeagueLogo(leagueId);
const logoUrl = getMediaUrl('league-logo', leagueId);
return (
<div className="mb-8">

View File

@@ -43,7 +43,7 @@ export default function LeagueMembers({
const byId: Record<string, DriverViewModel> = {};
for (const dto of driverDtos) {
byId[dto.id] = new DriverViewModel(dto);
byId[dto.id] = new DriverViewModel({ ...dto, avatarUrl: (dto as any).avatarUrl ?? null });
}
setDriversById(byId);
} else {

View File

@@ -48,6 +48,7 @@ export default function LeagueOwnershipTransfer({
driver={new DriverViewModel({
id: ownerSummary.driver.id,
name: ownerSummary.driver.name,
avatarUrl: (ownerSummary.driver as any).avatarUrl ?? null,
iracingId: ownerSummary.driver.iracingId,
country: ownerSummary.driver.country,
bio: ownerSummary.driver.bio,

View File

@@ -8,7 +8,8 @@ import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
import { LeagueRoleDisplay } from '@/lib/display-objects/LeagueRoleDisplay';
import CountryFlag from '@/components/ui/CountryFlag';
import { useServices } from '@/lib/services/ServiceProvider';
import { getMediaUrl } from '@/lib/utilities/media';
import PlaceholderImage from '@/components/ui/PlaceholderImage';
// Position background colors
const getPositionBgColor = (position: number): string => {
@@ -52,7 +53,6 @@ export default function StandingsTable({
onRemoveMember,
onUpdateRole
}: StandingsTableProps) {
const { mediaService } = useServices();
const [hoveredRow, setHoveredRow] = useState<string | null>(null);
const [activeMenu, setActiveMenu] = useState<{ driverId: string; type: 'member' | 'points' } | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
@@ -320,16 +320,20 @@ export default function StandingsTable({
{/* Avatar */}
<div className="relative">
<div className="w-10 h-10 rounded-full bg-primary-blue/20 overflow-hidden flex items-center justify-center shrink-0">
{driver && (
<Image
src={mediaService.getDriverAvatar(driver.id)}
alt={driver.name}
width={40}
height={40}
className="w-full h-full object-cover"
/>
)}
</div>
{driver && (
driver.avatarUrl ? (
<Image
src={driver.avatarUrl}
alt={driver.name}
width={40}
height={40}
className="w-full h-full object-cover"
/>
) : (
<PlaceholderImage size={40} />
)
)}
</div>
{/* Nationality flag */}
{driver && driver.country && (
<div className="absolute -bottom-1 -right-1">

View File

@@ -5,13 +5,13 @@ import Image from 'next/image';
import Link from 'next/link';
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import DriverRating from '@/components/profile/DriverRatingPill';
import { useServices } from '@/lib/services/ServiceProvider';
import PlaceholderImage from '@/components/ui/PlaceholderImage';
export interface DriverSummaryPillProps {
driver: DriverViewModel;
rating: number | null;
rank: number | null;
avatarSrc?: string;
avatarSrc?: string | null;
onClick?: () => void;
href?: string;
}
@@ -19,21 +19,22 @@ export interface DriverSummaryPillProps {
export default function DriverSummaryPill(props: DriverSummaryPillProps) {
const { driver, rating, rank, avatarSrc, onClick, href } = props;
const { mediaService } = useServices();
const resolvedAvatar =
avatarSrc ?? mediaService.getDriverAvatar(driver.id);
const resolvedAvatar = avatarSrc;
const content = (
<>
<div className="w-8 h-8 rounded-full overflow-hidden bg-charcoal-outline flex items-center justify-center border border-charcoal-outline/80">
<Image
src={resolvedAvatar}
alt={driver.name}
width={32}
height={32}
className="w-full h-full object-cover"
/>
{resolvedAvatar ? (
<Image
src={resolvedAvatar}
alt={driver.name}
width={32}
height={32}
className="w-full h-full object-cover"
/>
) : (
<PlaceholderImage size={32} />
)}
</div>
<div className="flex flex-col leading-tight text-left">

View File

@@ -5,7 +5,7 @@ import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import Button from '../ui/Button';
import DriverRatingPill from '@/components/profile/DriverRatingPill';
import CountryFlag from '@/components/ui/CountryFlag';
import { useServices } from '@/lib/services/ServiceProvider';
import PlaceholderImage from '@/components/ui/PlaceholderImage';
interface ProfileHeaderProps {
driver: DriverViewModel;
@@ -26,19 +26,21 @@ export default function ProfileHeader({
teamName,
teamTag,
}: ProfileHeaderProps) {
const { mediaService } = useServices();
return (
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-primary-blue to-purple-600 overflow-hidden flex items-center justify-center">
<Image
src={mediaService.getDriverAvatar(driver.id)}
alt={driver.name}
width={80}
height={80}
className="w-full h-full object-cover"
/>
{driver.avatarUrl ? (
<Image
src={driver.avatarUrl}
alt={driver.name}
width={80}
height={80}
className="w-full h-full object-cover"
/>
) : (
<PlaceholderImage size={80} />
)}
</div>
<div>

View File

@@ -19,9 +19,8 @@ vi.mock('@/hooks/useEffectiveDriverId', () => {
};
});
// Mock services hook to inject stub driverService/mediaService
const mockFindById = vi.fn<[], Promise<DriverDTO | null>>();
const mockGetDriverAvatar = vi.fn<(driverId: string) => string>();
// Mock services hook to inject stub driverService
const mockFindById = vi.fn();
vi.mock('@/lib/services/ServiceProvider', () => {
return {
@@ -30,7 +29,7 @@ vi.mock('@/lib/services/ServiceProvider', () => {
findById: mockFindById,
},
mediaService: {
getDriverAvatar: mockGetDriverAvatar,
getDriverAvatar: vi.fn(),
},
}),
};
@@ -66,7 +65,6 @@ describe('UserPill', () => {
mockedAuthValue = { session: null };
mockedDriverId = null;
mockFindById.mockReset();
mockGetDriverAvatar.mockReset();
});
it('renders auth links when there is no session', () => {
@@ -94,19 +92,19 @@ describe('UserPill', () => {
expect(mockFindById).not.toHaveBeenCalled();
});
it('loads driver via driverService and uses mediaService avatar', async () => {
it('loads driver via driverService and uses driver avatarUrl', async () => {
const driver: DriverDTO = {
id: 'driver-1',
iracingId: 'ir-123',
name: 'Test Driver',
country: 'DE',
avatarUrl: '/api/media/avatar/driver-1',
};
mockedAuthValue = { session: { user: { id: 'user-1' } } };
mockedDriverId = driver.id;
mockFindById.mockResolvedValue(driver);
mockGetDriverAvatar.mockImplementation((driverId: string) => `/api/media/avatar/${driverId}`);
render(<UserPill />);
@@ -115,6 +113,5 @@ describe('UserPill', () => {
});
expect(mockFindById).toHaveBeenCalledWith('driver-1');
expect(mockGetDriverAvatar).toHaveBeenCalledWith('driver-1');
});
});

View File

@@ -106,7 +106,7 @@ export default function UserPill() {
const dto = await driverService.findById(primaryDriverId);
if (!cancelled) {
setDriver(dto ? new DriverViewModelClass(dto) : null);
setDriver(dto ? new DriverViewModelClass({ ...dto, avatarUrl: (dto as any).avatarUrl ?? null }) : null);
}
}
@@ -127,7 +127,7 @@ export default function UserPill() {
const rating: number | null = null;
const rank: number | null = null;
const avatarSrc = mediaService.getDriverAvatar(primaryDriverId);
const avatarSrc = driver.avatarUrl;
return {
driver,
@@ -135,7 +135,7 @@ export default function UserPill() {
rating,
rank,
};
}, [session, driver, primaryDriverId, mediaService]);
}, [session, driver, primaryDriverId]);
// Close menu when clicking outside
useEffect(() => {

View File

@@ -1,7 +1,7 @@
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { useServices } from '@/lib/services/ServiceProvider';
import { getMediaUrl } from '@/lib/utilities/media';
interface Friend {
id: string;
@@ -23,8 +23,6 @@ function getCountryFlag(countryCode: string): string {
}
export default function FriendPill({ friend }: FriendPillProps) {
const { mediaService } = useServices();
return (
<Link
href={`/drivers/${friend.id}`}
@@ -32,7 +30,7 @@ export default function FriendPill({ friend }: FriendPillProps) {
>
<div className="w-8 h-8 rounded-full overflow-hidden bg-gradient-to-br from-primary-blue to-purple-600">
<Image
src={mediaService.getDriverAvatar(friend.id)}
src={getMediaUrl('driver-avatar', friend.id)}
alt={friend.name}
width={32}
height={32}

View File

@@ -1,5 +1,7 @@
import Image from 'next/image';
import { UserPlus, Users, Trophy } from 'lucide-react';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import { getMediaUrl } from '@/lib/utilities/media';
const SKILL_LEVELS: {
id: string;
@@ -77,8 +79,14 @@ export default function FeaturedRecruiting({ teams, onTeamClick }: FeaturedRecru
className="p-4 rounded-xl bg-iron-gray/60 border border-charcoal-outline hover:border-performance-green/40 transition-all duration-200 text-left group"
>
<div className="flex items-start justify-between mb-3">
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${levelConfig?.bgColor} border ${levelConfig?.borderColor}`}>
{/* LevelIcon would be here */}
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-charcoal-outline border border-charcoal-outline overflow-hidden">
<Image
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name}
width={32}
height={32}
className="w-full h-full object-cover"
/>
</div>
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] bg-performance-green/10 text-performance-green border border-performance-green/20">
<div className="w-1.5 h-1.5 rounded-full bg-performance-green animate-pulse" />

View File

@@ -17,7 +17,7 @@ import {
Languages,
} from 'lucide-react';
import { useServices } from '@/lib/services/ServiceProvider';
import PlaceholderImage from '@/components/ui/PlaceholderImage';
interface TeamCardProps {
id: string;
@@ -81,8 +81,7 @@ export default function TeamCard({
category,
onClick,
}: TeamCardProps) {
const { mediaService } = useServices();
const logoUrl = logo || mediaService.getTeamLogo(id);
const logoUrl = logo;
const performanceBadge = getPerformanceBadge(performanceLevel);
const specializationBadge = getSpecializationBadge(specialization);
@@ -98,13 +97,17 @@ export default function TeamCard({
<div className="flex items-start gap-4">
{/* Logo */}
<div className="w-14 h-14 rounded-xl bg-charcoal-outline flex items-center justify-center flex-shrink-0 overflow-hidden border border-charcoal-outline">
<Image
src={logoUrl}
alt={name}
width={56}
height={56}
className="w-full h-full object-cover"
/>
{logoUrl ? (
<Image
src={logoUrl}
alt={name}
width={56}
height={56}
className="w-full h-full object-cover"
/>
) : (
<PlaceholderImage size={56} />
)}
</div>
{/* Title & Badges */}

View File

@@ -1,8 +1,8 @@
'use client';
import { useServices } from '@/lib/services/ServiceProvider';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import { getMediaUrl } from '@/lib/utilities/media';
export interface TeamLadderRowProps {
rank: number;
@@ -26,8 +26,7 @@ export default function TeamLadderRow({
totalRaces,
}: TeamLadderRowProps) {
const router = useRouter();
const { mediaService } = useServices();
const logo = teamLogoUrl ?? mediaService.getTeamLogo(teamId);
const logo = teamLogoUrl ?? getMediaUrl('team-logo', teamId);
const handleClick = () => {
router.push(`/teams/${teamId}`);

View File

@@ -1,7 +1,9 @@
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import { Award, ChevronRight, Crown, Trophy, Users } from 'lucide-react';
import Button from '@/components/ui/Button';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import { getMediaUrl } from '@/lib/utilities/media';
const SKILL_LEVELS: {
id: string;
@@ -133,8 +135,14 @@ export default function TeamLeaderboardPreview({
</div>
{/* Team Info */}
<div className={`flex h-9 w-9 items-center justify-center rounded-lg ${levelConfig?.bgColor} border ${levelConfig?.borderColor}`}>
{/* LevelIcon */}
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-charcoal-outline border border-charcoal-outline overflow-hidden">
<Image
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name}
width={36}
height={36}
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate group-hover:text-purple-400 transition-colors">

View File

@@ -1,5 +1,7 @@
import Image from 'next/image';
import { Trophy, Crown, Users } from 'lucide-react';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import { getMediaUrl } from '@/lib/utilities/media';
const SKILL_LEVELS: {
id: string;
@@ -128,11 +130,15 @@ export default function TopThreePodium({ teams, onClick }: TopThreePodiumProps)
</div>
)}
{/* Team icon */}
<div
className={`flex h-16 w-16 md:h-20 md:w-20 items-center justify-center rounded-xl ${levelConfig?.bgColor} border ${levelConfig?.borderColor} mb-3`}
>
{/* LevelIcon */}
{/* Team logo */}
<div className="flex h-16 w-16 md:h-20 md:w-20 items-center justify-center rounded-xl bg-charcoal-outline border border-charcoal-outline overflow-hidden mb-3">
<Image
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name}
width={80}
height={80}
className="w-full h-full object-cover"
/>
</div>
{/* Team name */}

View File

@@ -0,0 +1,20 @@
import { User } from 'lucide-react';
export interface PlaceholderImageProps {
size?: number;
className?: string;
}
/**
* Shared placeholder image component for when no avatar/logo URL is available
*/
export default function PlaceholderImage({ size = 48, className = '' }: PlaceholderImageProps) {
return (
<div
className={`rounded-full bg-charcoal-outline flex items-center justify-center ${className}`}
style={{ width: size, height: size }}
>
<User className="w-6 h-6 text-gray-400" />
</div>
);
}

View File

@@ -0,0 +1,50 @@
/**
* Media configuration for GridPilot website.
* Single source of truth for all media asset paths and URLs.
*
* Note: This config should be kept in sync with the shared MediaAssetConfig
* in adapters/bootstrap/MediaAssetConfig.ts
*/
export interface MediaConfig {
avatars: {
defaultFallback: string;
paths: {
male: string;
female: string;
neutral: string;
};
};
api: {
avatar: (driverId: string) => string;
teamLogo: (teamId: string) => string;
trackImage: (trackId: string) => string;
sponsorLogo: (sponsorId: string) => string;
categoryIcon: (categoryId: string) => string;
};
}
export const mediaConfig: MediaConfig = {
avatars: {
// Default fallback used when no avatar URL is available
defaultFallback: '/images/avatars/neutral-default-avatar.jpeg',
// Individual avatar type paths
paths: {
male: '/images/avatars/male-default-avatar.jpg',
female: '/images/avatars/female-default-avatar.jpeg',
neutral: '/images/avatars/neutral-default-avatar.jpeg',
},
},
api: {
// Direct media paths (no /api/ prefix) - served by website or API
avatar: (driverId: string) => `/media/avatar/${driverId}`,
teamLogo: (teamId: string) => `/media/teams/${teamId}/logo`,
trackImage: (trackId: string) => `/media/tracks/${trackId}/image`,
sponsorLogo: (sponsorId: string) => `/media/sponsors/${sponsorId}/logo`,
categoryIcon: (categoryId: string) => `/media/categories/${categoryId}/icon`,
},
} as const;
export type MediaConfigType = typeof mediaConfig;

View File

@@ -41,7 +41,7 @@ export class DriverService {
if (!dto) {
return null;
}
return new DriverViewModel(dto);
return new DriverViewModel({ ...dto, avatarUrl: (dto as any).avatarUrl ?? null });
}
/**
@@ -55,7 +55,7 @@ export class DriverService {
id: dto.currentDriver.id,
name: dto.currentDriver.name,
country: dto.currentDriver.country,
avatarUrl: dto.currentDriver.avatarUrl,
avatarUrl: dto.currentDriver.avatarUrl || '',
iracingId: dto.currentDriver.iracingId ?? null,
joinedAt: dto.currentDriver.joinedAt,
rating: dto.currentDriver.rating ?? null,
@@ -107,7 +107,7 @@ export class DriverService {
id: f.id,
name: f.name,
country: f.country,
avatarUrl: f.avatarUrl,
avatarUrl: f.avatarUrl || '',
})),
},
extendedProfile: dto.extendedProfile

View File

@@ -33,7 +33,7 @@ export class LandingService {
const racesVm = new RacesPageViewModel(racesDto);
const topLeagues = leaguesDto.leagues.slice(0, 4).map(
const topLeagues = (leaguesDto?.leagues || []).slice(0, 4).map(
(league: LeagueWithCapacityDTO) => new LeagueCardViewModel({
id: league.id,
name: league.name,
@@ -41,13 +41,14 @@ export class LandingService {
}),
);
const teams = teamsDto.teams.slice(0, 4).map(
const teams = (teamsDto?.teams || []).slice(0, 4).map(
(team: TeamListItemDTO) =>
new TeamCardViewModel({
id: team.id,
name: team.name,
tag: team.tag,
description: team.description,
logoUrl: team.logoUrl,
}),
);

View File

@@ -114,6 +114,7 @@ export class LeagueService {
id: league.id,
name: league.name,
description: league.description,
logoUrl: league.logoUrl ?? null, // Use API-provided logo URL
ownerId: league.ownerId,
createdAt: league.createdAt,
maxDrivers: league.settings?.maxDrivers ?? 0,
@@ -611,4 +612,4 @@ export class LeagueService {
const result = await this.apiClient.getScoringPresets();
return result.presets;
}
}
}

View File

@@ -2,7 +2,6 @@ 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 };
@@ -42,35 +41,4 @@ export class MediaService {
return new DeleteMediaViewModel(dto);
}
/**
* Get team logo URL
* Returns relative URL for proxying through Next.js rewrites
*/
getTeamLogo(teamId: string): string {
return `/api/media/teams/${teamId}/logo`;
}
/**
* Get driver avatar URL
* Returns relative URL for proxying through Next.js rewrites
*/
getDriverAvatar(driverId: string): string {
return `/api/media/avatar/${driverId}`;
}
/**
* Get league cover URL
* Returns relative URL for proxying through Next.js rewrites
*/
getLeagueCover(leagueId: string): string {
return `/api/media/leagues/${leagueId}/cover`;
}
/**
* 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

@@ -34,7 +34,7 @@ export class PaymentService {
async getPayments(leagueId?: string, payerId?: string): Promise<PaymentViewModel[]> {
const query = (leagueId || payerId) ? { ...(leagueId && { leagueId }), ...(payerId && { payerId }) } : undefined;
const dto = await this.apiClient.getPayments(query);
return dto.payments.map((payment: PaymentDTO) => new PaymentViewModel(payment));
return (dto?.payments || []).map((payment: PaymentDTO) => new PaymentViewModel(payment));
}
/**
@@ -43,7 +43,7 @@ export class PaymentService {
async getPayment(paymentId: string): Promise<PaymentViewModel> {
// Note: Assuming the API returns a single payment from the list
const dto = await this.apiClient.getPayments();
const payment = dto.payments.find((p: PaymentDTO) => p.id === paymentId);
const payment = (dto?.payments || []).find((p: PaymentDTO) => p.id === paymentId);
if (!payment) {
throw new Error(`Payment with ID ${paymentId} not found`);
}
@@ -72,7 +72,7 @@ export class PaymentService {
async getPrizes(leagueId?: string, seasonId?: string): Promise<PrizeViewModel[]> {
const query = (leagueId || seasonId) ? { ...(leagueId && { leagueId }), ...(seasonId && { seasonId }) } : undefined;
const dto = await this.apiClient.getPrizes(query);
return dto.prizes.map((prize: PrizeDTO) => new PrizeViewModel(prize));
return (dto?.prizes || []).map((prize: PrizeDTO) => new PrizeViewModel(prize));
}
/**

View File

@@ -21,7 +21,7 @@ export class SponsorService {
*/
async getAllSponsors(): Promise<SponsorViewModel[]> {
const dto = await this.apiClient.getAll();
return dto.sponsors.map((sponsor: SponsorDTO) => new SponsorViewModel(sponsor));
return (dto?.sponsors || []).map((sponsor: SponsorDTO) => new SponsorViewModel(sponsor));
}
/**

View File

@@ -23,8 +23,8 @@ export class TeamJoinService {
* Get team join requests with view model transformation
*/
async getJoinRequests(teamId: string, currentUserId: string, isOwner: boolean): Promise<TeamJoinRequestViewModel[]> {
const dto = await this.apiClient.getJoinRequests(teamId) as TeamJoinRequestsDto;
return dto.requests.map((r: TeamJoinRequestDTO) => new TeamJoinRequestViewModel(r, currentUserId, isOwner));
const dto = await this.apiClient.getJoinRequests(teamId) as TeamJoinRequestsDto | null;
return (dto?.requests || []).map((r: TeamJoinRequestDTO) => new TeamJoinRequestViewModel(r, currentUserId, isOwner));
}
/**

View File

@@ -32,8 +32,8 @@ export class TeamService {
* Get all teams with view model transformation
*/
async getAllTeams(): Promise<TeamSummaryViewModel[]> {
const dto: GetAllTeamsOutputDTO = await this.apiClient.getAll();
return dto.teams.map((team: TeamListItemDTO) => new TeamSummaryViewModel(team));
const dto: GetAllTeamsOutputDTO | null = await this.apiClient.getAll();
return (dto?.teams || []).map((team: TeamListItemDTO) => new TeamSummaryViewModel(team));
}
/**

View File

@@ -34,9 +34,11 @@ export type LeagueWithCapacityAndScoringDTO = {
createdAt: string;
settings: LeagueCapacityAndScoringSettingsDTO;
usedSlots: number;
logoUrl?: string | null;
socialLinks?: LeagueCapacityAndScoringSocialLinksDTO;
scoring?: LeagueCapacityAndScoringSummaryScoringDTO;
timingSummary?: string;
category?: string;
};
export type AllLeaguesWithCapacityAndScoringDTO = {

View File

@@ -230,6 +230,30 @@ describe('Website Contract Consumption', () => {
// avatarUrls and requestId are optional in failure case
});
it('should handle URL|null pattern for media fields', () => {
// Test that media fields use URL|null pattern, not empty strings
const driverWithAvatar: DriverDTO = {
id: 'driver-123',
name: 'Test Driver',
avatarUrl: 'https://example.com/avatar.png',
country: 'US',
};
const driverWithoutAvatar: DriverDTO = {
id: 'driver-456',
name: 'No Avatar Driver',
avatarUrl: null,
country: 'UK',
};
expect(driverWithAvatar.avatarUrl).toBe('https://example.com/avatar.png');
expect(driverWithoutAvatar.avatarUrl).toBeNull();
// Should not use empty strings
expect(driverWithAvatar.avatarUrl).not.toBe('');
expect(driverWithoutAvatar.avatarUrl).not.toBeUndefined();
});
it('should allow type narrowing based on success flag', () => {
function handleAvatarResponse(response: RequestAvatarGenerationOutputDTO) {
if (response.success) {

View File

@@ -1,6 +1,6 @@
/**
* Auto-generated DTO from OpenAPI spec
* Spec SHA256: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* Spec SHA256: a5e14ed849c9c55f18facf00106e60c0557da2f69fa246fd717783f0cb0d80ab
* 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: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* Spec SHA256: a5e14ed849c9c55f18facf00106e60c0557da2f69fa246fd717783f0cb0d80ab
* 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: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* Spec SHA256: a5e14ed849c9c55f18facf00106e60c0557da2f69fa246fd717783f0cb0d80ab
* 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: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* Spec SHA256: a5e14ed849c9c55f18facf00106e60c0557da2f69fa246fd717783f0cb0d80ab
* 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: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* Spec SHA256: a5e14ed849c9c55f18facf00106e60c0557da2f69fa246fd717783f0cb0d80ab
* 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: c6aeaeed5d0c2f34dff6fe153482ffa67609b1132bb683867accabf828a8086e
* Spec SHA256: a5e14ed849c9c55f18facf00106e60c0557da2f69fa246fd717783f0cb0d80ab
* 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