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

@@ -16,7 +16,9 @@ NEXT_TELEMETRY_DISABLED=1
# GRIDPILOT_API_PERSISTENCE=postgres # GRIDPILOT_API_PERSISTENCE=postgres
# Force reseed on every startup in development # Force reseed on every startup in development
GRIDPILOT_API_FORCE_RESEED=true GRIDPILOT_API_FORCE_RESEED=1
GRIDPILOT_API_BOOTSTRAP=1
GRIDPILOT_API_PERSISTENCE=postgres
DATABASE_URL=postgres://gridpilot_user:gridpilot_dev_pass@db:5432/gridpilot_dev DATABASE_URL=postgres://gridpilot_user:gridpilot_dev_pass@db:5432/gridpilot_dev

View File

@@ -75,15 +75,15 @@ Supporting scripts:
## Environment Variables ## Environment Variables
### Mock vs Real (Website & API) ### "Mock vs Real" (Website & API)
There is **no** `AUTOMATION_MODE` equivalent for the Website/API runtime. There is **no** `AUTOMATION_MODE` equivalent for the Website/API runtime.
- **Website mock vs real** is controlled purely by *which API base URL you point it at* via [`getWebsiteApiBaseUrl()`](apps/website/lib/config/apiBaseUrl.ts:6): - **Website "mock vs real"** is controlled purely by *which API base URL you point it at* via [`getWebsiteApiBaseUrl()`](apps/website/lib/config/apiBaseUrl.ts:6):
- Browser calls use `NEXT_PUBLIC_API_BASE_URL` - Browser calls use `NEXT_PUBLIC_API_BASE_URL`
- Server/Next.js calls use `API_BASE_URL ?? NEXT_PUBLIC_API_BASE_URL` - Server/Next.js calls use `API_BASE_URL ?? NEXT_PUBLIC_API_BASE_URL`
- **API mock vs real** is controlled by API runtime env: - **API "mock vs real"** is controlled by API runtime env:
- Persistence: `GRIDPILOT_API_PERSISTENCE=postgres|inmemory` in [`AppModule`](apps/api/src/app.module.ts:25) - Persistence: `GRIDPILOT_API_PERSISTENCE=postgres|inmemory` in [`AppModule`](apps/api/src/app.module.ts:25)
- Optional bootstrapping: `GRIDPILOT_API_BOOTSTRAP=0|1` in [`AppModule`](apps/api/src/app.module.ts:35) - Optional bootstrapping: `GRIDPILOT_API_BOOTSTRAP=0|1` in [`AppModule`](apps/api/src/app.module.ts:35)
@@ -103,7 +103,7 @@ The website talks to the API via `fetch()` in [`BaseApiClient`](apps/website/lib
- The **browser** must be pointed at a host-accessible API URL via `NEXT_PUBLIC_API_BASE_URL` - The **browser** must be pointed at a host-accessible API URL via `NEXT_PUBLIC_API_BASE_URL`
- The **server** (Next.js / Node) must be pointed at a container-network API URL via `API_BASE_URL` (when running in Docker) - The **server** (Next.js / Node) must be pointed at a container-network API URL via `API_BASE_URL` (when running in Docker)
The single source of truth for what base URL should I use? is [`getWebsiteApiBaseUrl()`](apps/website/lib/config/apiBaseUrl.ts:6): The single source of truth for "what base URL should I use?" is [`getWebsiteApiBaseUrl()`](apps/website/lib/config/apiBaseUrl.ts:6):
- Browser: reads `NEXT_PUBLIC_API_BASE_URL` - Browser: reads `NEXT_PUBLIC_API_BASE_URL`
- Server: reads `API_BASE_URL ?? NEXT_PUBLIC_API_BASE_URL` - Server: reads `API_BASE_URL ?? NEXT_PUBLIC_API_BASE_URL`
- In Docker/CI/test: throws if missing (no silent localhost fallback) - In Docker/CI/test: throws if missing (no silent localhost fallback)
@@ -122,13 +122,13 @@ This stack is intended for deterministic smoke tests and uses different host por
- `NEXT_PUBLIC_API_BASE_URL=http://localhost:3101` (browser → host port) - `NEXT_PUBLIC_API_BASE_URL=http://localhost:3101` (browser → host port)
- `API_BASE_URL=http://api:3000` (website container → api container) - `API_BASE_URL=http://api:3000` (website container → api container)
Important: the test stacks API is a mock server defined inline in [`docker-compose.test.yml`](docker-compose.test.yml:24). It exists to validate Website ↔ API wiring, not domain correctness. Important: the test stack's API is a mock server defined inline in [`docker-compose.test.yml`](docker-compose.test.yml:24). It exists to validate Website ↔ API wiring, not domain correctness.
#### Troubleshooting #### Troubleshooting
- If `docker:dev` is running, use `npm run docker:dev:down` before `npm run test:docker:website` to avoid port conflicts. - If `docker:dev` is running, use `npm run docker:dev:down` before `npm run test:docker:website` to avoid port conflicts.
- If Docker volumes get stuck, run `npm run docker:test:down` (it uses `--remove-orphans` + `rm -f`). - If Docker volumes get stuck, run `npm run docker:test:down` (it uses `--remove-orphans` + `rm -f`).
### API Real vs In-Memory Mode ### API "Real vs In-Memory" Mode
The API can now be run either: The API can now be run either:
- **postgres**: loads [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:1) (requires Postgres) - **postgres**: loads [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:1) (requires Postgres)
@@ -227,6 +227,57 @@ docker-compose -f docker-compose.dev.yml logs -f website
docker-compose -f docker-compose.dev.yml logs -f db docker-compose -f docker-compose.dev.yml logs -f db
``` ```
### Database Migration for Media References
If you have existing seeded data with old URL formats (e.g., `/api/avatar/{id}`, `/api/media/teams/{id}/logo`), you need to migrate to the new `MediaReference` format.
#### Option 1: Migration Script (Preserve Data)
Run the migration script to convert old URLs to proper `MediaReference` objects:
```bash
# Test mode (dry run - shows what would change)
npm run migrate:media:test
# Execute migration (applies changes)
npm run migrate:media:exec
```
The script handles:
- **Driver avatars**: `/api/avatar/{id}``system-default` (deterministic variant)
- **Team logos**: `/api/media/teams/{id}/logo``generated`
- **League logos**: `/api/media/leagues/{id}/logo``generated`
- **Unknown formats** → `none`
#### Option 2: Wipe and Reseed (Clean Slate)
For development environments, you can wipe all data and start fresh:
```bash
# Stop services and remove volumes
npm run docker:dev:clean
# Rebuild and start fresh
npm run docker:dev:build
```
This will:
- Delete all existing data
- Run fresh seed with correct `MediaReference` format
- No migration needed
#### When to Use Each Option
**Use Migration Script** when:
- You have production data you want to preserve
- You want to understand what changes will be made
- You need a controlled, reversible process
**Use Wipe and Reseed** when:
- You're in development/testing
- You don't care about existing data
- You want the fastest path to a clean state
## Tips ## Tips
1. **First time setup**: Use `docker:dev:build` to ensure images are built 1. **First time setup**: Use `docker:dev:build` to ensure images are built

View File

@@ -0,0 +1,39 @@
/**
* Shared media asset configuration
* This file defines the paths for all media assets used across the application
*/
export interface MediaAssetConfig {
avatars: {
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;
};
}
/**
* Shared media asset paths configuration
* Used by both seed data generation and frontend components
*/
export const mediaAssetConfig: MediaAssetConfig = {
avatars: {
male: '/images/avatars/male-default-avatar.jpg',
female: '/images/avatars/female-default-avatar.jpeg',
neutral: '/images/avatars/neutral-default-avatar.jpeg',
},
api: {
avatar: (driverId: string) => `/api/media/avatar/${driverId}`,
teamLogo: (teamId: string) => `/api/media/teams/${teamId}/logo`,
trackImage: (trackId: string) => `/api/media/tracks/${trackId}/image`,
sponsorLogo: (sponsorId: string) => `/api/media/sponsors/${sponsorId}/logo`,
categoryIcon: (categoryId: string) => `/api/media/categories/${categoryId}/icon`,
},
} as const;

View File

@@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// IMPORTANT: SeedRacingData imports createRacingSeed from ./racing/RacingSeed
// We mock it to avoid heavy seed generation and to keep the test focused on
// force-reseed decision logic.
vi.mock('./racing/RacingSeed', () => {
return {
createRacingSeed: vi.fn(() => ({
drivers: [],
driverStats: new Map(),
leagues: [],
seasons: [],
seasonSponsorships: [],
sponsorshipRequests: [],
leagueWallets: [],
leagueWalletTransactions: [],
protests: [],
penalties: [],
races: [],
results: [],
standings: [],
leagueMemberships: [],
leagueJoinRequests: [],
raceRegistrations: [],
teams: [],
teamStats: new Map(),
teamMemberships: [],
teamJoinRequests: [],
sponsors: [],
tracks: [],
friendships: [],
feedEvents: [],
})),
};
});
import type { Logger } from '@core/shared/application';
import { SeedRacingData, type RacingSeedDependencies } from './SeedRacingData';
describe('SeedRacingData force reseed behavior', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it('clears existing racing data when force reseed is enabled even if drivers are empty (stale teams scenario)', async () => {
process.env.GRIDPILOT_API_FORCE_RESEED = '1';
process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory';
delete process.env.DATABASE_URL;
const logger: Logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const seedDeps = {
driverRepository: {
findAll: vi.fn().mockResolvedValue([]),
create: vi.fn(),
delete: vi.fn(),
},
leagueRepository: { findAll: vi.fn().mockResolvedValue([]), create: vi.fn(), delete: vi.fn() },
seasonRepository: { findByLeagueId: vi.fn().mockResolvedValue([]), create: vi.fn() },
leagueScoringConfigRepository: { findBySeasonId: vi.fn().mockResolvedValue(null), save: vi.fn() },
seasonSponsorshipRepository: { create: vi.fn() },
sponsorshipRequestRepository: { create: vi.fn() },
leagueWalletRepository: { create: vi.fn() },
transactionRepository: { create: vi.fn() },
protestRepository: { create: vi.fn() },
penaltyRepository: { create: vi.fn() },
raceRepository: { findAll: vi.fn().mockResolvedValue([]), create: vi.fn(), delete: vi.fn() },
resultRepository: { findAll: vi.fn().mockResolvedValue([]), createMany: vi.fn() },
standingRepository: { findAll: vi.fn().mockResolvedValue([]), saveMany: vi.fn() },
leagueMembershipRepository: {
saveMembership: vi.fn(),
saveJoinRequest: vi.fn(),
getJoinRequests: vi.fn().mockResolvedValue([]),
getLeagueMembers: vi.fn().mockResolvedValue([]),
removeJoinRequest: vi.fn(),
removeMembership: vi.fn(),
},
raceRegistrationRepository: { register: vi.fn(), clearRaceRegistrations: vi.fn() },
// STALE TEAMS PRESENT
teamRepository: {
findAll: vi.fn().mockResolvedValue([{ id: 't1' }]),
create: vi.fn(),
delete: vi.fn(),
},
teamMembershipRepository: {
saveMembership: vi.fn(),
saveJoinRequest: vi.fn(),
getJoinRequests: vi.fn().mockResolvedValue([]),
getTeamMembers: vi.fn().mockResolvedValue([]),
removeJoinRequest: vi.fn(),
removeMembership: vi.fn(),
},
sponsorRepository: { create: vi.fn() },
feedRepository: {},
socialGraphRepository: {},
driverStatsRepository: { clear: vi.fn(), saveDriverStats: vi.fn() },
teamStatsRepository: { clear: vi.fn(), saveTeamStats: vi.fn(), getTeamStats: vi.fn().mockResolvedValue(null) },
mediaRepository: { clear: vi.fn() },
} as unknown as RacingSeedDependencies;
const s = new SeedRacingData(logger, seedDeps);
// Spy on the private method by monkey-patching (this is the behavior under test)
const clearSpy = vi.fn().mockResolvedValue(undefined);
(s as unknown as { clearExistingRacingData: () => Promise<void> }).clearExistingRacingData = clearSpy;
await s.execute();
expect(clearSpy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -22,7 +22,7 @@ import type { IPenaltyRepository } from '@core/racing/domain/repositories/IPenal
import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository'; import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository';
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository'; import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
import type { IDriverStatsRepository } from '@core/racing/domain/repositories/IDriverStatsRepository'; import type { IDriverStatsRepository } from '@core/racing/domain/repositories/IDriverStatsRepository';
import type { ITeamStatsRepository } from '@core/racing/domain/repositories/ITeamStatsRepository'; import type { ITeamStatsRepository, TeamStats } from '@core/racing/domain/repositories/ITeamStatsRepository';
import type { IMediaRepository } from '@core/racing/domain/repositories/IMediaRepository'; import type { IMediaRepository } from '@core/racing/domain/repositories/IMediaRepository';
import { createRacingSeed } from './racing/RacingSeed'; import { createRacingSeed } from './racing/RacingSeed';
import { seedId } from './racing/SeedIdHelper'; import { seedId } from './racing/SeedIdHelper';
@@ -31,7 +31,6 @@ import { Result } from '@core/racing/domain/entities/result/Result';
import { Standing } from '@core/racing/domain/entities/Standing'; import { Standing } from '@core/racing/domain/entities/Standing';
import { Team } from '@core/racing/domain/entities/Team'; import { Team } from '@core/racing/domain/entities/Team';
import type { DriverStats } from '@core/racing/application/use-cases/IDriverStatsUseCase'; import type { DriverStats } from '@core/racing/application/use-cases/IDriverStatsUseCase';
import type { TeamStats } from '@core/racing/domain/repositories/ITeamStatsRepository';
export type RacingSeedDependencies = { export type RacingSeedDependencies = {
driverRepository: IDriverRepository; driverRepository: IDriverRepository;
@@ -78,26 +77,33 @@ export class SeedRacingData {
return process.env.DATABASE_URL ? 'postgres' : 'inmemory'; return process.env.DATABASE_URL ? 'postgres' : 'inmemory';
} }
private getMediaBaseUrl(): string {
return process.env.NODE_ENV === 'development' ? 'http://localhost:3001' : 'https://api.gridpilot.io';
}
async execute(): Promise<void> { async execute(): Promise<void> {
const existingDrivers = await this.seedDeps.driverRepository.findAll(); const existingDrivers = await this.seedDeps.driverRepository.findAll();
const existingTeams = await this.seedDeps.teamRepository.findAll().catch(() => []);
const persistence = this.getApiPersistence(); const persistence = this.getApiPersistence();
// Check for force reseed via environment variable // Check for force reseed via environment variable
const forceReseedRaw = process.env.GRIDPILOT_API_FORCE_RESEED; const forceReseedRaw = process.env.GRIDPILOT_API_FORCE_RESEED;
const forceReseed = forceReseedRaw !== undefined && forceReseedRaw !== '0' && forceReseedRaw.toLowerCase() !== 'false'; const forceReseed = forceReseedRaw !== undefined && forceReseedRaw !== '0' && forceReseedRaw.toLowerCase() !== 'false';
this.logger.info(
`[Bootstrap] Racing seed precheck: forceReseed=${forceReseed}, drivers=${existingDrivers.length}, teams=${existingTeams.length}, persistence=${persistence}`,
);
if (existingDrivers.length > 0 && !forceReseed) { if (existingDrivers.length > 0 && !forceReseed) {
this.logger.info('[Bootstrap] Racing seed skipped (drivers already exist), ensuring scoring configs'); this.logger.info('[Bootstrap] Racing seed skipped (drivers already exist), ensuring scoring configs');
await this.ensureScoringConfigsForExistingData(); await this.ensureScoringConfigsForExistingData();
return; return;
} }
if (forceReseed && existingDrivers.length > 0) { // IMPORTANT:
this.logger.info('[Bootstrap] Force reseed enabled - clearing existing racing data'); // Force reseed must clear even when drivers are already empty.
// Otherwise stale teams can remain (e.g. with logoRef=system-default/logo),
// and the seed will "ignore duplicates" on create, leaving stale logoRefs in Postgres.
if (forceReseed) {
this.logger.info(
`[Bootstrap] Force reseed enabled - clearing existing racing data (drivers=${existingDrivers.length}, teams=${existingTeams.length})`,
);
await this.clearExistingRacingData(); await this.clearExistingRacingData();
} }
@@ -109,9 +115,8 @@ export class SeedRacingData {
// Clear existing stats repositories // Clear existing stats repositories
await this.seedDeps.driverStatsRepository.clear(); await this.seedDeps.driverStatsRepository.clear();
await this.seedDeps.teamStatsRepository.clear(); await this.seedDeps.teamStatsRepository.clear();
await this.seedDeps.mediaRepository.clear();
this.logger.info('[Bootstrap] Cleared existing stats and media repositories'); this.logger.info('[Bootstrap] Cleared existing stats repositories');
let sponsorshipRequestsSeededViaRepo = false; let sponsorshipRequestsSeededViaRepo = false;
const seedableSponsorshipRequests = this.seedDeps const seedableSponsorshipRequests = this.seedDeps
@@ -316,9 +321,6 @@ export class SeedRacingData {
// Compute and store team stats from real data // Compute and store team stats from real data
await this.computeAndStoreTeamStats(); await this.computeAndStoreTeamStats();
// Seed media assets (logos, images)
await this.seedMediaAssets(seed);
this.logger.info( this.logger.info(
`[Bootstrap] Seeded racing data: drivers=${seed.drivers.length}, leagues=${seed.leagues.length}, races=${seed.races.length}`, `[Bootstrap] Seeded racing data: drivers=${seed.drivers.length}, leagues=${seed.leagues.length}, races=${seed.races.length}`,
@@ -429,7 +431,7 @@ export class SeedRacingData {
this.logger.info(`[Bootstrap] Computed and stored stats for ${teams.length} teams`); this.logger.info(`[Bootstrap] Computed and stored stats for ${teams.length} teams`);
} }
private calculateTeamStats(team: Team, results: Result[], drivers: Driver[]): TeamStats { private calculateTeamStats(_team: Team, results: Result[], drivers: Driver[]): TeamStats {
const wins = results.filter(r => r.position.toNumber() === 1).length; const wins = results.filter(r => r.position.toNumber() === 1).length;
const totalRaces = results.length; const totalRaces = results.length;
@@ -466,7 +468,6 @@ export class SeedRacingData {
}))); })));
return { return {
logoUrl: `${this.getMediaBaseUrl()}/api/media/teams/${team.id}/logo`,
performanceLevel, performanceLevel,
specialization, specialization,
region, region,
@@ -485,119 +486,171 @@ export class SeedRacingData {
return 'Beginner'; return 'Beginner';
} }
private async seedMediaAssets(seed: any): Promise<void> {
const baseUrl = this.getMediaBaseUrl();
// Seed driver avatars using static files
for (const driver of seed.drivers) {
const avatarUrl = this.getDriverAvatarUrl(driver.id);
const mediaRepo = this.seedDeps.mediaRepository as any;
if (mediaRepo.setDriverAvatar) {
mediaRepo.setDriverAvatar(driver.id, avatarUrl);
}
}
// Seed team logos using API routes
for (const team of seed.teams) {
const logoUrl = `${baseUrl}/api/media/teams/${team.id}/logo`;
const mediaRepo = this.seedDeps.mediaRepository as any;
if (mediaRepo.setTeamLogo) {
mediaRepo.setTeamLogo(team.id, logoUrl);
}
}
// Seed track images
for (const track of seed.tracks || []) {
const trackImageUrl = `${baseUrl}/api/media/tracks/${track.id}/image`;
const mediaRepo = this.seedDeps.mediaRepository as any;
if (mediaRepo.setTrackImage) {
mediaRepo.setTrackImage(track.id, trackImageUrl);
}
}
// Seed category icons (if categories exist)
const categories = ['beginner', 'intermediate', 'advanced', 'pro', 'endurance', 'sprint'];
for (const category of categories) {
const iconUrl = `${baseUrl}/api/media/categories/${category}/icon`;
const mediaRepo = this.seedDeps.mediaRepository as any;
if (mediaRepo.setCategoryIcon) {
mediaRepo.setCategoryIcon(category, iconUrl);
}
}
// Seed sponsor logos
for (const sponsor of seed.sponsors || []) {
const logoUrl = `${baseUrl}/api/media/sponsors/${sponsor.id}/logo`;
const mediaRepo = this.seedDeps.mediaRepository as any;
if (mediaRepo.setSponsorLogo) {
mediaRepo.setSponsorLogo(sponsor.id, logoUrl);
}
}
this.logger.info(`[Bootstrap] Seeded media assets for ${seed.drivers.length} drivers, ${seed.teams.length} teams`);
}
/**
* Get deterministic avatar URL for a driver based on their ID
* Uses static files from the website public directory
*/
private getDriverAvatarUrl(driverId: string): string {
// Deterministic selection based on driver ID
const numericSuffixMatch = driverId.match(/(\d+)$/);
let useFemale = false;
let useNeutral = false;
if (numericSuffixMatch && numericSuffixMatch[1]) {
const numericSuffix = parseInt(numericSuffixMatch[1], 10);
// 40% female, 40% male, 20% neutral
if (numericSuffix % 5 === 0) {
useNeutral = true;
} else if (numericSuffix % 2 === 0) {
useFemale = true;
}
} else {
// Fallback hash
let hash = 0;
for (let i = 0; i < driverId.length; i++) {
hash = (hash * 31 + driverId.charCodeAt(i)) | 0;
}
const hashValue = Math.abs(hash);
if (hashValue % 5 === 0) {
useNeutral = true;
} else if (hashValue % 2 === 0) {
useFemale = true;
}
}
// Return static file paths that Next.js can serve
if (useNeutral) {
return '/images/avatars/neutral-default-avatar.jpeg';
} else if (useFemale) {
return '/images/avatars/female-default-avatar.jpeg';
} else {
return '/images/avatars/male-default-avatar.jpg';
}
}
private async clearExistingRacingData(): Promise<void> { private async clearExistingRacingData(): Promise<void> {
// Get all existing drivers this.logger.info('[Bootstrap] Starting comprehensive clearing of all racing data');
const drivers = await this.seedDeps.driverRepository.findAll();
// Delete drivers first (this should cascade to related data in most cases) // Clear stats repositories first
for (const driver of drivers) { try {
try { await this.seedDeps.driverStatsRepository.clear();
await this.seedDeps.driverRepository.delete(driver.id); await this.seedDeps.teamStatsRepository.clear();
} catch { this.logger.info('[Bootstrap] Cleared stats repositories');
// Ignore errors } catch (error) {
} this.logger.warn('[Bootstrap] Could not clear stats repositories:', error);
} }
// Try to clean up other data if repositories support it // Clear race registrations - get all races first, then clear their registrations
try {
const races = await this.seedDeps.raceRepository.findAll();
for (const race of races) {
try {
await this.seedDeps.raceRegistrationRepository.clearRaceRegistrations(race.id.toString());
} catch {
// Ignore
}
}
this.logger.info('[Bootstrap] Cleared race registrations');
} catch (error) {
this.logger.warn('[Bootstrap] Could not clear race registrations:', error);
}
// Clear team join requests - get all teams first, then clear their join requests
try {
const teams = await this.seedDeps.teamRepository.findAll();
for (const team of teams) {
const joinRequests = await this.seedDeps.teamMembershipRepository.getJoinRequests(team.id.toString());
for (const request of joinRequests) {
try {
await this.seedDeps.teamMembershipRepository.removeJoinRequest(request.id);
} catch {
// Ignore
}
}
}
this.logger.info('[Bootstrap] Cleared team join requests');
} catch (error) {
this.logger.warn('[Bootstrap] Could not clear team join requests:', error);
}
// Clear team memberships
try {
const teams = await this.seedDeps.teamRepository.findAll();
for (const team of teams) {
const memberships = await this.seedDeps.teamMembershipRepository.getTeamMembers(team.id.toString());
for (const membership of memberships) {
try {
await this.seedDeps.teamMembershipRepository.removeMembership(team.id.toString(), membership.driverId.toString());
} catch {
// Ignore
}
}
}
this.logger.info('[Bootstrap] Cleared team memberships');
} catch (error) {
this.logger.warn('[Bootstrap] Could not clear team memberships:', error);
}
// Clear teams (this is critical - teams have stale logoRef)
try {
const teams = await this.seedDeps.teamRepository.findAll();
for (const team of teams) {
try {
await this.seedDeps.teamRepository.delete(team.id.toString());
} catch {
// Ignore
}
}
this.logger.info('[Bootstrap] Cleared teams');
} catch (error) {
this.logger.warn('[Bootstrap] Could not clear teams:', error);
}
// Clear results
try {
const results = await this.seedDeps.resultRepository.findAll();
for (const result of results) {
try {
await this.seedDeps.resultRepository.delete(result.id.toString());
} catch {
// Ignore
}
}
this.logger.info('[Bootstrap] Cleared results');
} catch (error) {
this.logger.warn('[Bootstrap] Could not clear results:', error);
}
// Clear standings
try {
const standings = await this.seedDeps.standingRepository.findAll();
for (const standing of standings) {
try {
await this.seedDeps.standingRepository.delete(standing.leagueId.toString(), standing.driverId.toString());
} catch {
// Ignore
}
}
this.logger.info('[Bootstrap] Cleared standings');
} catch (error) {
this.logger.warn('[Bootstrap] Could not clear standings:', error);
}
// Clear races
try {
const races = await this.seedDeps.raceRepository.findAll();
for (const race of races) {
try {
await this.seedDeps.raceRepository.delete(race.id.toString());
} catch {
// Ignore
}
}
this.logger.info('[Bootstrap] Cleared races');
} catch (error) {
this.logger.warn('[Bootstrap] Could not clear races:', error);
}
// Clear league join requests
try {
const leagues = await this.seedDeps.leagueRepository.findAll();
for (const league of leagues) {
const joinRequests = await this.seedDeps.leagueMembershipRepository.getJoinRequests(league.id.toString());
for (const request of joinRequests) {
try {
await this.seedDeps.leagueMembershipRepository.removeJoinRequest(request.id);
} catch {
// Ignore
}
}
}
this.logger.info('[Bootstrap] Cleared league join requests');
} catch (error) {
this.logger.warn('[Bootstrap] Could not clear league join requests:', error);
}
// Clear league memberships
try {
const leagues = await this.seedDeps.leagueRepository.findAll();
for (const league of leagues) {
const memberships = await this.seedDeps.leagueMembershipRepository.getLeagueMembers(league.id.toString());
for (const membership of memberships) {
try {
await this.seedDeps.leagueMembershipRepository.removeMembership(league.id.toString(), membership.driverId.toString());
} catch {
// Ignore
}
}
}
this.logger.info('[Bootstrap] Cleared league memberships');
} catch (error) {
this.logger.warn('[Bootstrap] Could not clear league memberships:', error);
}
// Note: Some repositories don't support direct deletion methods
// The key fix is clearing teams, team memberships, and join requests
// which resolves the logoRef issue
// Clear leagues
try { try {
const leagues = await this.seedDeps.leagueRepository.findAll(); const leagues = await this.seedDeps.leagueRepository.findAll();
for (const league of leagues) { for (const league of leagues) {
@@ -607,11 +660,48 @@ export class SeedRacingData {
// Ignore // Ignore
} }
} }
} catch { this.logger.info('[Bootstrap] Cleared leagues');
// Ignore } catch (error) {
this.logger.warn('[Bootstrap] Could not clear leagues:', error);
} }
this.logger.info('[Bootstrap] Cleared existing racing data'); // Clear drivers (do this last as other data depends on it)
try {
const drivers = await this.seedDeps.driverRepository.findAll();
for (const driver of drivers) {
try {
await this.seedDeps.driverRepository.delete(driver.id);
} catch {
// Ignore
}
}
this.logger.info('[Bootstrap] Cleared drivers');
} catch (error) {
this.logger.warn('[Bootstrap] Could not clear drivers:', error);
}
// Clear social data if repositories support it
try {
const seedableFeed = this.seedDeps.feedRepository as unknown as { clear?: () => void };
if (typeof seedableFeed.clear === 'function') {
seedableFeed.clear();
this.logger.info('[Bootstrap] Cleared feed repository');
}
} catch (error) {
this.logger.warn('[Bootstrap] Could not clear feed repository:', error);
}
try {
const seedableSocial = this.seedDeps.socialGraphRepository as unknown as { clear?: () => void };
if (typeof seedableSocial.clear === 'function') {
seedableSocial.clear();
this.logger.info('[Bootstrap] Cleared social graph repository');
}
} catch (error) {
this.logger.warn('[Bootstrap] Could not clear social graph repository:', error);
}
this.logger.info('[Bootstrap] Completed comprehensive clearing of all racing data');
} }
private async ensureScoringConfigsForExistingData(): Promise<void> { private async ensureScoringConfigsForExistingData(): Promise<void> {
@@ -676,4 +766,4 @@ export class SeedRacingData {
return 'club-default'; return 'club-default';
} }
} }

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import { RacingDriverFactory } from './RacingDriverFactory';
import { MediaReference } from '@core/domain/media/MediaReference';
describe('RacingDriverFactory', () => {
describe('getDriverAvatarRef', () => {
it('should return deterministic MediaReference based on driver ID', () => {
const factory = new RacingDriverFactory(10, new Date(), 'inmemory');
// Test deterministic behavior
const ref1 = factory.getDriverAvatarRef('driver-1');
const ref2 = factory.getDriverAvatarRef('driver-1');
expect(ref1.equals(ref2)).toBe(true);
expect(ref1.type).toBe('system-default');
expect(ref1.variant).toBe('avatar');
});
it('should produce different refs for different IDs', () => {
const factory = new RacingDriverFactory(10, new Date(), 'inmemory');
const ref1 = factory.getDriverAvatarRef('driver-1');
const ref2 = factory.getDriverAvatarRef('driver-2');
// They should be different refs (though both are system-default avatar)
// The hash will be different
expect(ref1.hash()).not.toBe(ref2.hash());
});
it('should use hash % 3 for variant selection', () => {
const factory = new RacingDriverFactory(10, new Date(), 'inmemory');
// Test multiple IDs to ensure distribution
const refs = [
factory.getDriverAvatarRef('driver-1'),
factory.getDriverAvatarRef('driver-2'),
factory.getDriverAvatarRef('driver-3'),
factory.getDriverAvatarRef('driver-4'),
factory.getDriverAvatarRef('driver-5'),
];
// All should be system-default avatar
refs.forEach(ref => {
expect(ref.type).toBe('system-default');
expect(ref.variant).toBe('avatar');
});
});
});
describe('create', () => {
it('should create drivers with avatarRef set', () => {
const factory = new RacingDriverFactory(5, new Date(), 'inmemory');
const drivers = factory.create();
expect(drivers.length).toBe(5);
drivers.forEach(driver => {
expect(driver.avatarRef).toBeDefined();
expect(driver.avatarRef instanceof MediaReference).toBe(true);
expect(driver.avatarRef.type).toBe('system-default');
expect(driver.avatarRef.variant).toBe('avatar');
});
});
it('should create deterministic drivers', () => {
const factory1 = new RacingDriverFactory(3, new Date('2024-01-01'), 'inmemory');
const factory2 = new RacingDriverFactory(3, new Date('2024-01-01'), 'inmemory');
const drivers1 = factory1.create();
const drivers2 = factory2.create();
expect(drivers1.length).toBe(drivers2.length);
for (let i = 0; i < drivers1.length; i++) {
const driver1 = drivers1[i]!;
const driver2 = drivers2[i]!;
expect(driver1.id).toBe(driver2.id);
expect(driver1.avatarRef.equals(driver2.avatarRef)).toBe(true);
}
});
});
});

View File

@@ -1,4 +1,5 @@
import { Driver } from '@core/racing/domain/entities/Driver'; import { Driver } from '@core/racing/domain/entities/Driver';
import { MediaReference } from '@core/domain/media/MediaReference';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import { seedId } from './SeedIdHelper'; import { seedId } from './SeedIdHelper';
@@ -26,22 +27,23 @@ export class RacingDriverFactory {
) {} ) {}
/** /**
* Get deterministic avatar URL for a driver based on their ID * Get deterministic MediaReference for a driver's avatar based on their ID
* Uses static files from the website public directory * Uses hash % 3 to determine variant: 0 -> male, 1 -> female, 2 -> neutral
*/ */
getDriverAvatarUrl(driverId: string): string { getDriverAvatarRef(driverId: string): MediaReference {
// Deterministic selection based on driver ID // Deterministic selection based on driver ID hash
const numericSuffixMatch = driverId.match(/(\d+)$/); const numericSuffixMatch = driverId.match(/(\d+)$/);
let useFemale = false; let avatarVariant: 'male' | 'female' | 'neutral';
let useNeutral = false;
if (numericSuffixMatch && numericSuffixMatch[1]) { if (numericSuffixMatch && numericSuffixMatch[1]) {
const numericSuffix = parseInt(numericSuffixMatch[1], 10); const numericSuffix = parseInt(numericSuffixMatch[1], 10);
// 40% female, 40% male, 20% neutral const hashMod = numericSuffix % 3;
if (numericSuffix % 5 === 0) { if (hashMod === 0) {
useNeutral = true; avatarVariant = 'male';
} else if (numericSuffix % 2 === 0) { } else if (hashMod === 1) {
useFemale = true; avatarVariant = 'female';
} else {
avatarVariant = 'neutral';
} }
} else { } else {
// Fallback hash // Fallback hash
@@ -49,22 +51,18 @@ export class RacingDriverFactory {
for (let i = 0; i < driverId.length; i++) { for (let i = 0; i < driverId.length; i++) {
hash = (hash * 31 + driverId.charCodeAt(i)) | 0; hash = (hash * 31 + driverId.charCodeAt(i)) | 0;
} }
const hashValue = Math.abs(hash); const hashMod = Math.abs(hash) % 3;
if (hashValue % 5 === 0) { if (hashMod === 0) {
useNeutral = true; avatarVariant = 'male';
} else if (hashValue % 2 === 0) { } else if (hashMod === 1) {
useFemale = true; avatarVariant = 'female';
} else {
avatarVariant = 'neutral';
} }
} }
// Return static file paths that Next.js can serve // Create system-default reference with avatar variant
if (useNeutral) { return MediaReference.systemDefault(avatarVariant);
return '/images/avatars/neutral-default-avatar.jpeg';
} else if (useFemale) {
return '/images/avatars/female-default-avatar.jpeg';
} else {
return '/images/avatars/male-default-avatar.jpg';
}
} }
create(): Driver[] { create(): Driver[] {
@@ -99,6 +97,8 @@ export class RacingDriverFactory {
// Assign category - use all available categories // Assign category - use all available categories
const category = faker.helpers.arrayElement(categories); const category = faker.helpers.arrayElement(categories);
const driverId = seedId(`driver-${i}`, this.persistence);
const driverData: { const driverData: {
id: string; id: string;
iracingId: string; iracingId: string;
@@ -107,13 +107,15 @@ export class RacingDriverFactory {
bio?: string; bio?: string;
joinedAt?: Date; joinedAt?: Date;
category?: string; category?: string;
avatarRef: MediaReference;
} = { } = {
id: seedId(`driver-${i}`, this.persistence), id: driverId,
iracingId: String(100000 + i), iracingId: String(100000 + i),
name: faker.person.fullName(), name: faker.person.fullName(),
country: faker.helpers.arrayElement(countries), country: faker.helpers.arrayElement(countries),
joinedAt, joinedAt,
category, category,
avatarRef: this.getDriverAvatarRef(driverId),
}; };
if (hasBio) { if (hasBio) {

View File

@@ -1,5 +1,6 @@
import { League, LeagueSettings } from '@core/racing/domain/entities/League'; import { League, LeagueSettings } from '@core/racing/domain/entities/League';
import { Driver } from '@core/racing/domain/entities/Driver'; import { Driver } from '@core/racing/domain/entities/Driver';
import { MediaReference } from '@core/domain/media/MediaReference';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import { seedId } from './SeedIdHelper'; import { seedId } from './SeedIdHelper';
@@ -389,6 +390,7 @@ export class RacingLeagueFactory {
websiteUrl?: string; websiteUrl?: string;
}; };
participantCount?: number; participantCount?: number;
logoRef?: MediaReference;
} = { } = {
id: leagueData.id, id: leagueData.id,
name: leagueData.name, name: leagueData.name,
@@ -398,6 +400,7 @@ export class RacingLeagueFactory {
category: leagueData.category, category: leagueData.category,
createdAt: leagueData.createdAt, createdAt: leagueData.createdAt,
participantCount: leagueData.participantCount, participantCount: leagueData.participantCount,
logoRef: MediaReference.generated('league', leagueData.id),
}; };
if (Object.keys(socialLinks).length > 0) { if (Object.keys(socialLinks).length > 0) {

View File

@@ -13,7 +13,7 @@ export class RacingSponsorFactory {
id: seedId('demo-sponsor-1', this.persistence), id: seedId('demo-sponsor-1', this.persistence),
name: 'GridPilot Sim Racing Supply', name: 'GridPilot Sim Racing Supply',
contactEmail: 'partnerships@gridpilot.example', contactEmail: 'partnerships@gridpilot.example',
logoUrl: 'http://localhost:3000/images/header.jpeg', logoUrl: 'http://localhost:3001/images/header.jpeg',
websiteUrl: 'https://gridpilot.example/sponsors/gridpilot-sim-racing-supply', websiteUrl: 'https://gridpilot.example/sponsors/gridpilot-sim-racing-supply',
createdAt: faker.date.past({ years: 2, refDate: this.baseDate }), createdAt: faker.date.past({ years: 2, refDate: this.baseDate }),
}); });
@@ -74,13 +74,13 @@ export class RacingSponsorFactory {
]; ];
const logoPaths = [ const logoPaths = [
'http://localhost:3000/images/header.jpeg', 'http://localhost:3001/images/header.jpeg',
'http://localhost:3000/images/ff1600.jpeg', 'http://localhost:3001/images/ff1600.jpeg',
'http://localhost:3000/images/avatars/male-default-avatar.jpg', 'http://localhost:3001/images/avatars/male-default-avatar.jpg',
'http://localhost:3000/images/avatars/female-default-avatar.jpeg', 'http://localhost:3001/images/avatars/female-default-avatar.jpeg',
'http://localhost:3000/images/avatars/neutral-default-avatar.jpeg', 'http://localhost:3001/images/avatars/neutral-default-avatar.jpeg',
'http://localhost:3000/images/leagues/placeholder-cover.svg', 'http://localhost:3001/images/leagues/placeholder-cover.svg',
'http://localhost:3000/favicon.svg', 'http://localhost:3001/favicon.svg',
]; ];
const websiteUrls = [ const websiteUrls = [

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { RacingTeamFactory } from './RacingTeamFactory';
import { MediaReference } from '@core/domain/media/MediaReference';
import { RacingDriverFactory } from './RacingDriverFactory';
import { RacingLeagueFactory } from './RacingLeagueFactory';
describe('RacingTeamFactory', () => {
describe('createTeams', () => {
it('should create teams with generated logoRef', () => {
const baseDate = new Date();
const driverFactory = new RacingDriverFactory(10, baseDate, 'inmemory');
const leagueFactory = new RacingLeagueFactory(baseDate, driverFactory.create(), 'inmemory');
const teamFactory = new RacingTeamFactory(baseDate, 'inmemory');
const drivers = driverFactory.create();
const leagues = leagueFactory.create();
const teams = teamFactory.createTeams(drivers, leagues);
expect(teams.length).toBeGreaterThan(0);
teams.forEach(team => {
expect(team.logoRef).toBeDefined();
expect(team.logoRef instanceof MediaReference).toBe(true);
expect(team.logoRef.type).toBe('generated');
expect(team.logoRef.generationRequestId).toBe(`team-${team.id}`);
});
});
it('should create deterministic teams', () => {
const baseDate = new Date('2024-01-01');
const driverFactory = new RacingDriverFactory(5, baseDate, 'inmemory');
const leagueFactory = new RacingLeagueFactory(baseDate, driverFactory.create(), 'inmemory');
const drivers = driverFactory.create();
const leagues = leagueFactory.create();
const teamFactory1 = new RacingTeamFactory(baseDate, 'inmemory');
const teamFactory2 = new RacingTeamFactory(baseDate, 'inmemory');
const teams1 = teamFactory1.createTeams(drivers, leagues);
const teams2 = teamFactory2.createTeams(drivers, leagues);
expect(teams1.length).toBe(teams2.length);
for (let i = 0; i < teams1.length; i++) {
const team1 = teams1[i]!;
const team2 = teams2[i]!;
expect(team1.id).toBe(team2.id);
expect(team1.logoRef.equals(team2.logoRef)).toBe(true);
}
});
});
});

View File

@@ -1,12 +1,12 @@
import { Driver } from '@core/racing/domain/entities/Driver'; import { Driver } from '@core/racing/domain/entities/Driver';
import { League } from '@core/racing/domain/entities/League'; import { League } from '@core/racing/domain/entities/League';
import { Team } from '@core/racing/domain/entities/Team'; import { Team } from '@core/racing/domain/entities/Team';
import { MediaReference } from '@core/domain/media/MediaReference';
import type { TeamJoinRequest, TeamMembership } from '@core/racing/domain/types/TeamMembership'; import type { TeamJoinRequest, TeamMembership } from '@core/racing/domain/types/TeamMembership';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import { seedId } from './SeedIdHelper'; import { seedId } from './SeedIdHelper';
export interface TeamStats { export interface TeamStats {
logoUrl: string;
performanceLevel: 'beginner' | 'intermediate' | 'advanced' | 'pro'; performanceLevel: 'beginner' | 'intermediate' | 'advanced' | 'pro';
specialization: 'endurance' | 'sprint' | 'mixed'; specialization: 'endurance' | 'sprint' | 'mixed';
region: string; region: string;
@@ -37,8 +37,10 @@ export class RacingTeamFactory {
// 30-50% of teams are recruiting // 30-50% of teams are recruiting
const isRecruiting = faker.datatype.boolean({ probability: 0.4 }); const isRecruiting = faker.datatype.boolean({ probability: 0.4 });
const teamId = seedId(`team-${i}`, this.persistence);
return Team.create({ return Team.create({
id: seedId(`team-${i}`, this.persistence), id: teamId,
name: faker.company.name() + ' Racing', name: faker.company.name() + ' Racing',
tag: faker.string.alpha({ length: 4, casing: 'upper' }), tag: faker.string.alpha({ length: 4, casing: 'upper' }),
description: faker.lorem.sentences(2), description: faker.lorem.sentences(2),
@@ -46,6 +48,7 @@ export class RacingTeamFactory {
leagues: teamLeagues, leagues: teamLeagues,
isRecruiting, isRecruiting,
createdAt: faker.date.past({ years: 2, refDate: this.baseDate }), createdAt: faker.date.past({ years: 2, refDate: this.baseDate }),
logoRef: MediaReference.generated('team', teamId),
}); });
}); });
} }
@@ -200,16 +203,6 @@ export class RacingTeamFactory {
generateTeamStats(teams: Team[]): Map<string, TeamStats> { generateTeamStats(teams: Team[]): Map<string, TeamStats> {
const statsMap = new Map<string, TeamStats>(); const statsMap = new Map<string, TeamStats>();
// Available logo URLs (simulating media uploads)
const logoUrls = [
'/images/ff1600.jpeg',
'/images/header.jpeg',
'/images/avatars/male-default-avatar.jpg',
'/images/avatars/female-default-avatar.jpeg',
'/images/avatars/neutral-default-avatar.jpeg',
'/images/leagues/placeholder-cover.svg',
];
// Available regions // Available regions
const regions = ['Europe', 'North America', 'South America', 'Asia', 'Oceania', 'Africa']; const regions = ['Europe', 'North America', 'South America', 'Asia', 'Oceania', 'Africa'];
@@ -270,11 +263,7 @@ export class RacingTeamFactory {
const languageCount = faker.number.int({ min: 1, max: 3 }); const languageCount = faker.number.int({ min: 1, max: 3 });
const languages = faker.helpers.arrayElements(allLanguages, languageCount); const languages = faker.helpers.arrayElements(allLanguages, languageCount);
// Generate logo URL (varied)
const logoUrl = logoUrls[i % logoUrls.length] ?? logoUrls[0];
statsMap.set(team.id.toString(), { statsMap.set(team.id.toString(), {
logoUrl: logoUrl!,
performanceLevel, performanceLevel,
specialization, specialization,
region, region,

View File

@@ -0,0 +1,229 @@
/**
* TDD Tests for MediaResolverAdapter and its components
*
* Tests the complete resolution flow for all media reference types
*/
import { describe, expect, it } from 'vitest';
import { MediaReference } from '@core/domain/media/MediaReference';
import { MediaResolverAdapter, DefaultResolvers } from './MediaResolverAdapter';
import { DefaultMediaResolverAdapter } from './resolvers/DefaultMediaResolverAdapter';
import { GeneratedMediaResolverAdapter } from './resolvers/GeneratedMediaResolverAdapter';
import { UploadedMediaResolverAdapter } from './resolvers/UploadedMediaResolverAdapter';
describe('DefaultMediaResolverAdapter', () => {
const adapter = new DefaultMediaResolverAdapter();
describe('System Default URLs', () => {
it('should resolve avatar default without variant', async () => {
const ref = MediaReference.createSystemDefault('avatar');
const url = await adapter.resolve(ref);
expect(url).toBe('/media/default/neutral-default-avatar.png');
});
it('should resolve male avatar default', async () => {
const ref = MediaReference.createSystemDefault('avatar', 'male');
const url = await adapter.resolve(ref);
expect(url).toBe('/media/default/male-default-avatar.png');
});
it('should resolve female avatar default', async () => {
const ref = MediaReference.createSystemDefault('avatar', 'female');
const url = await adapter.resolve(ref);
expect(url).toBe('/media/default/female-default-avatar.png');
});
it('should resolve neutral avatar default', async () => {
const ref = MediaReference.createSystemDefault('avatar', 'neutral');
const url = await adapter.resolve(ref);
expect(url).toBe('/media/default/neutral-default-avatar.png');
});
it('should resolve team logo default', async () => {
const ref = MediaReference.createSystemDefault('logo');
const url = await adapter.resolve(ref);
expect(url).toBe('/media/default/logo.png');
});
it('should resolve league logo default', async () => {
const ref = MediaReference.createSystemDefault('logo');
const url = await adapter.resolve(ref);
expect(url).toBe('/media/default/logo.png');
});
it('should return null for non-system-default references', async () => {
const ref = MediaReference.createGenerated('team-123');
const url = await adapter.resolve(ref);
expect(url).toBeNull();
});
});
});
describe('GeneratedMediaResolverAdapter', () => {
const adapter = new GeneratedMediaResolverAdapter();
describe('Generated URLs', () => {
it('should resolve team logo generated', async () => {
const ref = MediaReference.createGenerated('team-123');
const url = await adapter.resolve(ref);
expect(url).toBe('/media/teams/123/logo');
});
it('should resolve league logo generated', async () => {
const ref = MediaReference.createGenerated('league-456');
const url = await adapter.resolve(ref);
expect(url).toBe('/media/leagues/456/logo');
});
it('should resolve driver avatar generated', async () => {
const ref = MediaReference.createGenerated('driver-789');
const url = await adapter.resolve(ref);
expect(url).toBe('/media/avatar/789');
});
it('should handle complex type names with hyphens', async () => {
const ref = MediaReference.createGenerated('team-league-123');
const url = await adapter.resolve(ref);
expect(url).toBe('/media/teams/league-123/logo');
});
it('should return null for invalid format (no hyphen)', async () => {
const ref = MediaReference.createGenerated('invalid');
const url = await adapter.resolve(ref);
expect(url).toBeNull();
});
it('should return null for non-generated references', async () => {
const ref = MediaReference.createSystemDefault('avatar');
const url = await adapter.resolve(ref);
expect(url).toBeNull();
});
});
});
describe('UploadedMediaResolverAdapter', () => {
const adapter = new UploadedMediaResolverAdapter();
describe('Uploaded URLs', () => {
it('should resolve uploaded media', async () => {
const ref = MediaReference.createUploaded('media-123');
const url = await adapter.resolve(ref);
expect(url).toBe('/media/uploaded/media-123');
});
it('should handle different media IDs', async () => {
const ref = MediaReference.createUploaded('media-456');
const url = await adapter.resolve(ref);
expect(url).toBe('/media/uploaded/media-456');
});
it('should return null for non-uploaded references', async () => {
const ref = MediaReference.createSystemDefault('avatar');
const url = await adapter.resolve(ref);
expect(url).toBeNull();
});
});
});
describe('MediaResolverAdapter (Composite)', () => {
const resolver = new MediaResolverAdapter();
describe('Composite Resolution', () => {
it('should resolve system-default references', async () => {
const ref = MediaReference.createSystemDefault('avatar', 'male');
const url = await resolver.resolve(ref);
expect(url).toBe('/media/default/male-default-avatar.png');
});
it('should resolve generated references', async () => {
const ref = MediaReference.createGenerated('team-123');
const url = await resolver.resolve(ref);
expect(url).toBe('/media/teams/123/logo');
});
it('should resolve uploaded references', async () => {
const ref = MediaReference.createUploaded('media-456');
const url = await resolver.resolve(ref);
expect(url).toBe('/media/uploaded/media-456');
});
it('should return null for none references', async () => {
const ref = MediaReference.createNone();
const url = await resolver.resolve(ref);
expect(url).toBeNull();
});
it('should return null for null/undefined input', async () => {
expect(await resolver.resolve(null as unknown as MediaReference)).toBeNull();
expect(await resolver.resolve(undefined as unknown as MediaReference)).toBeNull();
});
});
describe('Factory Functions', () => {
it('should create local resolver', () => {
const local = DefaultResolvers.local();
// Local resolver should work without baseUrl (path-only)
expect(local).toBeInstanceOf(MediaResolverAdapter);
});
it('should create production resolver', () => {
const prod = DefaultResolvers.production();
// Production resolver should work without baseUrl (path-only)
expect(prod).toBeInstanceOf(MediaResolverAdapter);
});
});
});
describe('Integration: End-to-End Resolution', () => {
const resolver = new MediaResolverAdapter();
it('should resolve all reference types consistently', async () => {
const testCases = [
{
ref: MediaReference.createSystemDefault('avatar', 'male'),
expected: '/media/default/male-default-avatar.png'
},
{
ref: MediaReference.createSystemDefault('avatar', 'female'),
expected: '/media/default/female-default-avatar.png'
},
{
ref: MediaReference.createSystemDefault('logo'),
expected: '/media/default/logo.png'
},
{
ref: MediaReference.createGenerated('team-abc123'),
expected: '/media/teams/abc123/logo'
},
{
ref: MediaReference.createGenerated('league-def456'),
expected: '/media/leagues/def456/logo'
},
{
ref: MediaReference.createUploaded('media-ghi789'),
expected: '/media/uploaded/media-ghi789'
},
{
ref: MediaReference.createNone(),
expected: null
}
];
for (const testCase of testCases) {
const result = await resolver.resolve(testCase.ref);
expect(result).toBe(testCase.expected);
}
});
it('should maintain URL consistency across multiple resolutions', async () => {
const ref = MediaReference.createGenerated('team-123');
const url1 = await resolver.resolve(ref);
const url2 = await resolver.resolve(ref);
const url3 = await resolver.resolve(ref);
expect(url1).toBe(url2);
expect(url2).toBe(url3);
expect(url1).toBe('/media/teams/123/logo');
});
});

View File

@@ -0,0 +1,127 @@
/**
* MediaResolverAdapter (Composite)
*
* Composite adapter that delegates resolution to type-specific adapters.
* This is the main entry point for media resolution.
*/
import { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
import { MediaReference } from '@core/domain/media/MediaReference';
import { DefaultMediaResolverAdapter } from './resolvers/DefaultMediaResolverAdapter';
import { GeneratedMediaResolverAdapter } from './resolvers/GeneratedMediaResolverAdapter';
import { UploadedMediaResolverAdapter } from './resolvers/UploadedMediaResolverAdapter';
/**
* Configuration for the composite MediaResolverAdapter
*/
export interface MediaResolverAdapterConfig {
/**
* Base path for default assets (defaults to '/media/default')
*/
defaultPath?: string;
/**
* Base path for generated assets (defaults to '/media/generated')
*/
generatedPath?: string;
/**
* Base path for uploaded assets (defaults to '/media/uploaded')
*/
uploadedPath?: string;
}
/**
* MediaResolverAdapter
*
* Composite adapter that delegates to type-specific resolvers.
* Implements the MediaResolverPort interface.
*
* Returns path-only URLs (e.g., /media/teams/123/logo) without baseUrl.
*
* Usage:
* ```typescript
* const resolver = new MediaResolverAdapter({
* defaultPath: '/media/default',
* generatedPath: '/media/generated',
* uploadedPath: '/media/uploaded'
* });
*
* const path = await resolver.resolve(mediaReference);
* ```
*/
export class MediaResolverAdapter implements MediaResolverPort {
private readonly defaultResolver: DefaultMediaResolverAdapter;
private readonly generatedResolver: GeneratedMediaResolverAdapter;
private readonly uploadedResolver: UploadedMediaResolverAdapter;
constructor(config: MediaResolverAdapterConfig = {}) {
// Initialize type-specific resolvers
this.defaultResolver = new DefaultMediaResolverAdapter({
basePath: config.defaultPath
});
this.generatedResolver = new GeneratedMediaResolverAdapter({
basePath: config.generatedPath
});
this.uploadedResolver = new UploadedMediaResolverAdapter({
basePath: config.uploadedPath
});
}
/**
* Resolve a media reference to a path-only URL
*
* Delegates to the appropriate type-specific resolver based on the reference type.
* Returns paths like /media/... (no baseUrl).
*/
async resolve(ref: MediaReference): Promise<string | null> {
if (!ref) {
return null;
}
// Delegate to the appropriate resolver based on type
switch (ref.type) {
case 'system-default':
return this.defaultResolver.resolve(ref);
case 'generated':
return this.generatedResolver.resolve(ref);
case 'uploaded':
return this.uploadedResolver.resolve(ref);
case 'none':
return null;
default:
// Unknown type
return null;
}
}
}
/**
* Factory function for creating MediaResolverAdapter instances
*/
export function createMediaResolver(
config: MediaResolverAdapterConfig = {}
): MediaResolverAdapter {
return new MediaResolverAdapter(config);
}
/**
* Default configuration for development/testing
*/
export const DefaultResolvers = {
/**
* Creates a resolver for local development
*/
local: () => createMediaResolver({}),
/**
* Creates a resolver for production
*/
production: () => createMediaResolver({})
};

View File

@@ -0,0 +1,229 @@
/**
* In-Memory Media Resolver Adapter
*
* Stub implementation for testing purposes.
* Resolves MediaReference objects to fake URLs without external dependencies.
*
* Part of the adapters layer, implementing the MediaResolverPort interface.
*/
import { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
import { MediaReference } from '@core/domain/media/MediaReference';
/**
* Configuration for InMemoryMediaResolverAdapter
*/
export interface InMemoryMediaResolverConfig {
/**
* Base URL to use for generated URLs
* @default 'https://fake-media.example.com'
*/
baseUrl?: string;
/**
* Whether to simulate network delays
* @default false
*/
simulateDelay?: boolean;
/**
* Delay in milliseconds when simulateDelay is true
* @default 50
*/
delayMs?: number;
/**
* Whether to return null for certain reference types (simulating missing media)
* @default false
*/
simulateMissingMedia?: boolean;
}
/**
* In-Memory Media Resolver Adapter
*
* Stub implementation that resolves media references to fake URLs.
* Designed for use in tests and development environments.
*
* @example
* ```typescript
* const adapter = new InMemoryMediaResolverAdapter({
* baseUrl: 'https://test.example.com',
* simulateDelay: true
* });
*
* const ref = MediaReference.createSystemDefault('avatar');
* const url = await adapter.resolve(ref);
* // Returns: '/media/default/male-default-avatar.png'
* ```
*/
export class InMemoryMediaResolverAdapter implements MediaResolverPort {
private readonly config: Required<InMemoryMediaResolverConfig>;
constructor(config: InMemoryMediaResolverConfig = {}) {
this.config = {
baseUrl: config.baseUrl ?? 'https://fake-media.example.com',
simulateDelay: config.simulateDelay ?? false,
delayMs: config.delayMs ?? 50,
simulateMissingMedia: config.simulateMissingMedia ?? false,
};
}
/**
* Resolve a media reference to a path-only URL
*
* @param ref - The media reference to resolve
* @returns Promise resolving to path string or null
*/
async resolve(ref: MediaReference): Promise<string | null> {
// Simulate network delay if configured
if (this.config.simulateDelay) {
await this.delay(this.config.delayMs);
}
// Simulate missing media for some cases
if (this.config.simulateMissingMedia && this.shouldReturnNull()) {
return null;
}
switch (ref.type) {
case 'system-default':
let filename: string;
if (ref.variant === 'avatar' && ref.avatarVariant) {
filename = `${ref.avatarVariant}-default-avatar.png`;
} else if (ref.variant === 'avatar') {
filename = `neutral-default-avatar.png`;
} else {
filename = `${ref.variant}.png`;
}
return `/media/default/${filename}`;
case 'generated':
// Parse the generationRequestId to extract type and id
// Format: "{type}-{id}" where id can contain hyphens
if (ref.generationRequestId) {
const firstHyphenIndex = ref.generationRequestId.indexOf('-');
if (firstHyphenIndex !== -1) {
const type = ref.generationRequestId.substring(0, firstHyphenIndex);
const id = ref.generationRequestId.substring(firstHyphenIndex + 1);
// Use the correct API routes
if (type === 'team') {
return `/media/teams/${id}/logo`;
} else if (type === 'league') {
return `/media/leagues/${id}/logo`;
} else if (type === 'driver') {
return `/media/avatar/${id}`;
}
// Fallback for other types
return `/media/generated/${type}/${id}`;
}
}
// Fallback for unexpected format
return null;
case 'uploaded':
return `/media/uploaded/${ref.mediaId}`;
case 'none':
return null;
default:
return null;
}
}
/**
* Simulate network delay
*/
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Determine if this reference should return null (simulating missing media)
*/
private shouldReturnNull(): boolean {
// Randomly return null for 20% of cases
return Math.random() < 0.2;
}
/**
* Get the configured base URL
*/
getBaseUrl(): string {
return this.config.baseUrl;
}
/**
* Update configuration
*/
updateConfig(config: Partial<InMemoryMediaResolverConfig>): void {
Object.assign(this.config, config);
}
/**
* Reset to default configuration
*/
reset(): void {
this.config.baseUrl = 'https://fake-media.example.com';
this.config.simulateDelay = false;
this.config.delayMs = 50;
this.config.simulateMissingMedia = false;
}
}
/**
* Factory function to create a configured in-memory resolver
*/
export function createInMemoryResolver(
config: InMemoryMediaResolverConfig = {}
): MediaResolverPort {
return new InMemoryMediaResolverAdapter(config);
}
/**
* Pre-configured resolver for common test scenarios
*/
export const TestResolvers = {
/**
* Fast resolver with no delays
*/
fast: () => new InMemoryMediaResolverAdapter({
baseUrl: 'https://test.example.com',
simulateDelay: false,
}),
/**
* Slow resolver that simulates network latency
*/
slow: () => new InMemoryMediaResolverAdapter({
baseUrl: 'https://test.example.com',
simulateDelay: true,
delayMs: 200,
}),
/**
* Unreliable resolver that sometimes returns null
*/
unreliable: () => new InMemoryMediaResolverAdapter({
baseUrl: 'https://test.example.com',
simulateMissingMedia: true,
}),
/**
* Custom base URL resolver
*/
withBaseUrl: (baseUrl: string) => new InMemoryMediaResolverAdapter({
baseUrl,
simulateDelay: false,
}),
/**
* Local development resolver
*/
local: () => new InMemoryMediaResolverAdapter({
baseUrl: 'http://localhost:3000/media',
simulateDelay: false,
}),
} as const;

View File

@@ -0,0 +1,166 @@
/**
* FileSystemMediaStorageAdapter
*
* Concrete adapter for storing media files on the filesystem.
* Implements the MediaStoragePort interface.
*/
import { MediaStoragePort, UploadOptions, UploadResult } from '@core/media/application/ports/MediaStoragePort';
import * as fs from 'fs/promises';
import * as path from 'path';
/**
* Configuration for FileSystemMediaStorageAdapter
*/
export interface FileSystemMediaStorageConfig {
/**
* Base directory for storing media files
* @default '/data/media'
*/
baseDir?: string;
}
/**
* FileSystemMediaStorageAdapter
*
* Stores media files in a local filesystem directory.
* Uses deterministic storage keys based on mediaId.
*/
export class FileSystemMediaStorageAdapter implements MediaStoragePort {
private readonly baseDir: string;
constructor(config: FileSystemMediaStorageConfig = {}) {
this.baseDir = config.baseDir || '/data/media';
}
/**
* Upload a media file to the filesystem
*
* @param buffer File buffer
* @param options Upload options
* @returns Upload result with storage key
*/
async uploadMedia(buffer: Buffer, options: UploadOptions): Promise<UploadResult> {
try {
// Validate content type
const allowedTypes = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/gif'];
if (!allowedTypes.includes(options.mimeType)) {
return {
success: false,
errorMessage: `Content type ${options.mimeType} is not allowed`,
};
}
// Generate deterministic storage key
const mediaId = this.generateMediaId(options.filename);
const storageKey = `uploaded/${mediaId}`;
const filePath = path.join(this.baseDir, storageKey);
// Ensure directory exists
await fs.mkdir(path.dirname(filePath), { recursive: true });
// Write file
await fs.writeFile(filePath, buffer);
return {
success: true,
filename: options.filename,
url: storageKey, // Return storage key, not full URL
};
} catch (error) {
return {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Delete a media file from the filesystem
*
* @param storageKey Storage key (e.g., 'uploaded/media-123')
*/
async deleteMedia(storageKey: string): Promise<void> {
try {
const filePath = path.join(this.baseDir, storageKey);
await fs.unlink(filePath);
} catch (error) {
// Ignore if file doesn't exist
if (error instanceof Error && 'code' in error && (error as any).code === 'ENOENT') {
return;
}
throw error;
}
}
/**
* Get file bytes as Buffer
*
* @param storageKey Storage key
* @returns Buffer or null if not found
*/
async getBytes(storageKey: string): Promise<Buffer | null> {
try {
const filePath = path.join(this.baseDir, storageKey);
return await fs.readFile(filePath);
} catch (error) {
return null;
}
}
/**
* Get file metadata
*
* @param storageKey Storage key
* @returns File metadata or null if not found
*/
async getMetadata(storageKey: string): Promise<{ size: number; contentType: string } | null> {
try {
const filePath = path.join(this.baseDir, storageKey);
const stat = await fs.stat(filePath);
// Determine content type from extension
const ext = path.extname(filePath).toLowerCase();
const contentType = this.getContentTypeFromExtension(ext);
return {
size: stat.size,
contentType,
};
} catch (error) {
return null;
}
}
/**
* Generate a deterministic media ID from filename
*/
private generateMediaId(filename: string): string {
const timestamp = Date.now();
const cleanFilename = filename.replace(/[^a-zA-Z0-9.-]/g, '_');
return `media-${timestamp}-${cleanFilename}`;
}
/**
* Get content type from file extension
*/
private getContentTypeFromExtension(ext: string): string {
const map: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.gif': 'image/gif',
};
return map[ext] || 'application/octet-stream';
}
}
/**
* Factory function for creating FileSystemMediaStorageAdapter instances
*/
export function createFileSystemMediaStorage(
config: FileSystemMediaStorageConfig = {}
): FileSystemMediaStorageAdapter {
return new FileSystemMediaStorageAdapter(config);
}

View File

@@ -13,9 +13,9 @@ describe('InMemoryImageServiceAdapter', () => {
const adapter = new InMemoryImageServiceAdapter(logger); const adapter = new InMemoryImageServiceAdapter(logger);
expect(adapter.getDriverAvatar('driver-1')).toContain('/images/avatars/'); expect(adapter.getDriverAvatar('driver-1')).toBe('/media/avatar/driver-1');
expect(adapter.getTeamLogo('team-1')).toBe('/images/ff1600.jpeg'); expect(adapter.getTeamLogo('team-1')).toBe('/media/teams/team-1/logo');
expect(adapter.getLeagueCover('league-1')).toBe('/images/header.jpeg'); expect(adapter.getLeagueCover('league-1')).toBe('/media/leagues/league-1/cover');
expect(adapter.getLeagueLogo('league-1')).toBe('/images/ff1600.jpeg'); expect(adapter.getLeagueLogo('league-1')).toBe('/media/leagues/league-1/logo');
}); });
}); });

View File

@@ -0,0 +1,79 @@
/**
* DefaultMediaResolverAdapter
*
* Resolves system-default media references to public asset URLs.
* Part of the adapters layer, implementing the MediaResolverPort interface.
*/
import { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
import { MediaReference } from '@core/domain/media/MediaReference';
/**
* Configuration for the DefaultMediaResolverAdapter
*/
export interface DefaultMediaResolverConfig {
/**
* Base path for default assets (defaults to '/media/default')
*/
basePath?: string | undefined;
}
/**
* DefaultMediaResolverAdapter
*
* Resolves system-default media references to public asset URLs.
*
* URL format: /media/default/{variant}
* Examples:
* - /media/default/male-default-avatar
* - /media/default/female-default-avatar
* - /media/default/neutral-default-avatar
* - /media/default/team-logo.png
* - /media/default/league-logo.png
*/
export class DefaultMediaResolverAdapter implements MediaResolverPort {
private readonly basePath: string;
constructor(config: DefaultMediaResolverConfig = {}) {
this.basePath = config.basePath || '/media/default';
}
/**
* Resolve a system-default media reference to a path-only URL
* Returns paths like /media/default/{variant} (no baseUrl)
*/
async resolve(ref: MediaReference): Promise<string | null> {
// Only handle system-default references
if (ref.type !== 'system-default') {
return null;
}
// Determine the filename based on variant and avatarVariant
let filename: string;
if (ref.variant === 'avatar' && ref.avatarVariant) {
// Driver avatars must use website public assets:
// apps/website/public/images/avatars/{male|female|neutral}-default-avatar.(jpg|jpeg)
// We intentionally keep the URL extension-less; MediaController maps it to the real file.
filename = `${ref.avatarVariant}-default-avatar`;
} else if (ref.variant === 'avatar') {
// Avatar without specific variant (fallback to neutral)
filename = `neutral-default-avatar`;
} else {
// Other variants (team, league, etc.)
filename = `${ref.variant}.png`;
}
// Return path-only URL
return `${this.basePath}/${filename}`;
}
}
/**
* Factory function for creating DefaultMediaResolverAdapter instances
*/
export function createDefaultMediaResolver(
config: DefaultMediaResolverConfig = {}
): DefaultMediaResolverAdapter {
return new DefaultMediaResolverAdapter(config);
}

View File

@@ -0,0 +1,92 @@
/**
* GeneratedMediaResolverAdapter
*
* Resolves generated media references to image serving URLs.
* Part of the adapters layer, implementing the MediaResolverPort interface.
*/
import { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
import { MediaReference } from '@core/domain/media/MediaReference';
/**
* Configuration for the GeneratedMediaResolverAdapter
*/
export interface GeneratedMediaResolverConfig {
/**
* Base path for generated assets (defaults to '/media/generated')
* @deprecated No longer used - returns path-only URLs
*/
basePath?: string | undefined;
}
/**
* GeneratedMediaResolverAdapter
*
* Resolves generated media references to image serving URLs.
*
* URL format: /media/generated/{type}/{id}
* Examples:
* - /media/teams/{id}/logo
* - /media/leagues/{id}/logo
* - /media/avatar/{id}
*
* The type and id are extracted from the generationRequestId.
* Format: "{type}-{id}" (e.g., "team-123", "league-456")
*/
export class GeneratedMediaResolverAdapter implements MediaResolverPort {
constructor(_config: GeneratedMediaResolverConfig = {}) {
// basePath is not used since we return path-only URLs
// config.basePath is ignored for backward compatibility
}
/**
* Resolve a generated media reference to a path-only URL
* Returns paths like /media/teams/{id}/logo (no baseUrl)
*/
async resolve(ref: MediaReference): Promise<string | null> {
// Only handle generated references
if (ref.type !== 'generated') {
return null;
}
// Parse the generationRequestId to extract type and id
// Format: "{type}-{id}" or "{type}-{subtype}-{id}"
const requestId = ref.generationRequestId;
if (!requestId) {
return null;
}
// Find the first hyphen to split type and id
// Format: "{type}-{id}" where id can contain hyphens
const firstHyphenIndex = requestId.indexOf('-');
if (firstHyphenIndex === -1) {
// Invalid format
return null;
}
const type = requestId.substring(0, firstHyphenIndex);
const id = requestId.substring(firstHyphenIndex + 1);
// Return path-only URLs matching the API routes
if (type === 'team') {
return `/media/teams/${id}/logo`;
} else if (type === 'league') {
return `/media/leagues/${id}/logo`;
} else if (type === 'driver') {
return `/media/avatar/${id}`;
}
// Fallback for other types
return `/media/generated/${type}/${id}`;
}
}
/**
* Factory function for creating GeneratedMediaResolverAdapter instances
*/
export function createGeneratedMediaResolver(
config: GeneratedMediaResolverConfig = {}
): GeneratedMediaResolverAdapter {
return new GeneratedMediaResolverAdapter(config);
}

View File

@@ -0,0 +1,71 @@
/**
* UploadedMediaResolverAdapter
*
* Resolves uploaded media references to image serving URLs.
* Part of the adapters layer, implementing the MediaResolverPort interface.
*/
import { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
import { MediaReference } from '@core/domain/media/MediaReference';
/**
* Configuration for the UploadedMediaResolverAdapter
*/
export interface UploadedMediaResolverConfig {
/**
* Base path for uploaded assets (defaults to '/media/uploaded')
*/
basePath?: string | undefined;
}
/**
* UploadedMediaResolverAdapter
*
* Resolves uploaded media references to image serving URLs.
*
* URL format: /media/uploaded/{mediaId}
* Examples:
* - /media/uploaded/media-123
* - /media/uploaded/media-456
*
* Note: This is a stub implementation. In production, this would:
* - Check if the media exists in storage
* - Handle different file types (images, videos, documents)
* - Handle access control and permissions
* - Generate signed URLs for private media
*/
export class UploadedMediaResolverAdapter implements MediaResolverPort {
private readonly basePath: string;
constructor(config: UploadedMediaResolverConfig = {}) {
this.basePath = config.basePath || '/media/uploaded';
}
/**
* Resolve an uploaded media reference to a path-only URL
* Returns paths like /media/uploaded/{mediaId} (no baseUrl)
*/
async resolve(ref: MediaReference): Promise<string | null> {
// Only handle uploaded references
if (ref.type !== 'uploaded') {
return null;
}
// Validate mediaId exists
if (!ref.mediaId) {
return null;
}
// Return path-only URL
return `${this.basePath}/${ref.mediaId}`;
}
}
/**
* Factory function for creating UploadedMediaResolverAdapter instances
*/
export function createUploadedMediaResolver(
config: UploadedMediaResolverConfig = {}
): UploadedMediaResolverAdapter {
return new UploadedMediaResolverAdapter(config);
}

View File

@@ -2,6 +2,7 @@ import { vi, describe, it, expect, beforeEach } from 'vitest';
import { InMemoryDriverRepository } from './InMemoryDriverRepository'; import { InMemoryDriverRepository } from './InMemoryDriverRepository';
import { Driver } from '@core/racing/domain/entities/Driver'; import { Driver } from '@core/racing/domain/entities/Driver';
import type { Logger } from '@core/shared/application'; import type { Logger } from '@core/shared/application';
import { MediaReference } from '@core/domain/media/MediaReference';
describe('InMemoryDriverRepository', () => { describe('InMemoryDriverRepository', () => {
let repository: InMemoryDriverRepository; let repository: InMemoryDriverRepository;
@@ -17,13 +18,23 @@ describe('InMemoryDriverRepository', () => {
repository = new InMemoryDriverRepository(mockLogger); repository = new InMemoryDriverRepository(mockLogger);
}); });
const createTestDriver = (id: string, iracingId: string, name: string, country: string) => { const createTestDriver = (id: string, iracingId: string, name: string, country: string, avatarRef?: MediaReference) => {
return Driver.create({ const props: {
id: string;
iracingId: string;
name: string;
country: string;
avatarRef?: MediaReference;
} = {
id, id,
iracingId, iracingId,
name, name,
country, country,
}); };
if (avatarRef !== undefined) {
props.avatarRef = avatarRef;
}
return Driver.create(props);
}; };
describe('constructor', () => { describe('constructor', () => {
@@ -188,4 +199,115 @@ describe('InMemoryDriverRepository', () => {
expect(result).toBe(false); expect(result).toBe(false);
}); });
}); });
describe('serialization with MediaReference', () => {
it('should serialize driver with uploaded avatarRef', async () => {
const driver = createTestDriver('1', '12345', 'Test Driver', 'US', MediaReference.createUploaded('media-123'));
await repository.create(driver);
const serialized = repository.serialize(driver);
expect(serialized.avatarRef).toEqual({ type: 'uploaded', mediaId: 'media-123' });
});
it('should serialize driver with system-default avatarRef', async () => {
const driver = createTestDriver('1', '12345', 'Test Driver', 'US', MediaReference.createSystemDefault('avatar'));
await repository.create(driver);
const serialized = repository.serialize(driver);
expect(serialized.avatarRef).toEqual({ type: 'system-default', variant: 'avatar' });
});
it('should serialize driver with generated avatarRef', async () => {
const driver = createTestDriver('1', '12345', 'Test Driver', 'US', MediaReference.createGenerated('gen-123'));
await repository.create(driver);
const serialized = repository.serialize(driver);
expect(serialized.avatarRef).toEqual({ type: 'generated', generationRequestId: 'gen-123' });
});
it('should deserialize driver with uploaded avatarRef', () => {
const data = {
id: '1',
iracingId: '12345',
name: 'Test Driver',
country: 'US',
bio: null,
joinedAt: new Date().toISOString(),
category: null,
avatarRef: { type: 'uploaded', mediaId: 'media-123' },
};
const driver = repository.deserialize(data);
expect(driver.id).toBe('1');
expect(driver.avatarRef.type).toBe('uploaded');
expect(driver.avatarRef.mediaId).toBe('media-123');
});
it('should deserialize driver with system-default avatarRef', () => {
const data = {
id: '1',
iracingId: '12345',
name: 'Test Driver',
country: 'US',
bio: null,
joinedAt: new Date().toISOString(),
category: null,
avatarRef: { type: 'system-default', variant: 'avatar' },
};
const driver = repository.deserialize(data);
expect(driver.id).toBe('1');
expect(driver.avatarRef.type).toBe('system-default');
expect(driver.avatarRef.variant).toBe('avatar');
});
it('should deserialize driver with generated avatarRef', () => {
const data = {
id: '1',
iracingId: '12345',
name: 'Test Driver',
country: 'US',
bio: null,
joinedAt: new Date().toISOString(),
category: null,
avatarRef: { type: 'generated', generationRequestId: 'gen-123' },
};
const driver = repository.deserialize(data);
expect(driver.id).toBe('1');
expect(driver.avatarRef.type).toBe('generated');
expect(driver.avatarRef.generationRequestId).toBe('gen-123');
});
it('should deserialize driver without avatarRef (backward compatibility)', () => {
const data = {
id: '1',
iracingId: '12345',
name: 'Test Driver',
country: 'US',
bio: null,
joinedAt: new Date().toISOString(),
category: null,
};
const driver = repository.deserialize(data);
expect(driver.id).toBe('1');
expect(driver.avatarRef.type).toBe('system-default');
expect(driver.avatarRef.variant).toBe('avatar');
});
it('should roundtrip serialize and deserialize with avatarRef', async () => {
const originalDriver = createTestDriver('1', '12345', 'Test Driver', 'US', MediaReference.createUploaded('media-456'));
await repository.create(originalDriver);
const serialized = repository.serialize(originalDriver);
const deserialized = repository.deserialize(serialized);
expect(deserialized.id).toBe(originalDriver.id);
expect(deserialized.iracingId.toString()).toBe(originalDriver.iracingId.toString());
expect(deserialized.name.toString()).toBe(originalDriver.name.toString());
expect(deserialized.country.toString()).toBe(originalDriver.country.toString());
expect(deserialized.avatarRef.equals(originalDriver.avatarRef)).toBe(true);
});
});
}); });

View File

@@ -1,6 +1,7 @@
import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository'; import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
import { Driver } from '@core/racing/domain/entities/Driver'; import { Driver } from '@core/racing/domain/entities/Driver';
import { Logger } from '@core/shared/application'; import { Logger } from '@core/shared/application';
import { MediaReference } from '@core/domain/media/MediaReference';
export class InMemoryDriverRepository implements IDriverRepository { export class InMemoryDriverRepository implements IDriverRepository {
private drivers: Map<string, Driver> = new Map(); private drivers: Map<string, Driver> = new Map();
@@ -91,4 +92,49 @@ export class InMemoryDriverRepository implements IDriverRepository {
this.logger.debug(`[InMemoryDriverRepository] Checking existence of driver with iRacing ID: ${iracingId}`); this.logger.debug(`[InMemoryDriverRepository] Checking existence of driver with iRacing ID: ${iracingId}`);
return Promise.resolve(this.iracingIdIndex.has(iracingId)); return Promise.resolve(this.iracingIdIndex.has(iracingId));
} }
}
// Serialization methods for persistence
serialize(driver: Driver): Record<string, unknown> {
return {
id: driver.id,
iracingId: driver.iracingId.toString(),
name: driver.name.toString(),
country: driver.country.toString(),
bio: driver.bio?.toString() ?? null,
joinedAt: driver.joinedAt.toDate().toISOString(),
category: driver.category ?? null,
avatarRef: driver.avatarRef.toJSON(),
};
}
deserialize(data: Record<string, unknown>): Driver {
const props: {
id: string;
iracingId: string;
name: string;
country: string;
bio?: string;
joinedAt: Date;
category?: string;
avatarRef?: MediaReference;
} = {
id: data.id as string,
iracingId: data.iracingId as string,
name: data.name as string,
country: data.country as string,
joinedAt: new Date(data.joinedAt as string),
};
if (data.bio !== null && data.bio !== undefined) {
props.bio = data.bio as string;
}
if (data.category !== null && data.category !== undefined) {
props.category = data.category as string;
}
if (data.avatarRef !== null && data.avatarRef !== undefined) {
props.avatarRef = MediaReference.fromJSON(data.avatarRef as Record<string, unknown>);
}
return Driver.rehydrate(props);
}
}

View File

@@ -1,6 +1,7 @@
import { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository'; import { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
import { League } from '@core/racing/domain/entities/League'; import { League } from '@core/racing/domain/entities/League';
import { Logger } from '@core/shared/application'; import { Logger } from '@core/shared/application';
import { MediaReference } from '@core/domain/media/MediaReference';
export class InMemoryLeagueRepository implements ILeagueRepository { export class InMemoryLeagueRepository implements ILeagueRepository {
private leagues: Map<string, League> = new Map(); private leagues: Map<string, League> = new Map();
@@ -132,4 +133,71 @@ export class InMemoryLeagueRepository implements ILeagueRepository {
throw error; throw error;
} }
} }
}
// Serialization methods for persistence
serialize(league: League): Record<string, unknown> {
return {
id: league.id.toString(),
name: league.name.toString(),
description: league.description.toString(),
ownerId: league.ownerId.toString(),
settings: league.settings,
category: league.category ?? null,
createdAt: league.createdAt.toDate().toISOString(),
participantCount: league.getParticipantCount(),
socialLinks: league.socialLinks
? {
discordUrl: league.socialLinks.discordUrl,
youtubeUrl: league.socialLinks.youtubeUrl,
websiteUrl: league.socialLinks.websiteUrl,
}
: undefined,
logoRef: league.logoRef.toJSON(),
};
}
deserialize(data: Record<string, unknown>): League {
const props: {
id: string;
name: string;
description: string;
ownerId: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
settings: any;
category?: string;
createdAt: Date;
participantCount: number;
socialLinks?: {
discordUrl?: string;
youtubeUrl?: string;
websiteUrl?: string;
};
logoRef?: MediaReference;
} = {
id: data.id as string,
name: data.name as string,
description: data.description as string,
ownerId: data.ownerId as string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
settings: data.settings as any,
createdAt: new Date(data.createdAt as string),
participantCount: data.participantCount as number,
};
if (data.category !== null && data.category !== undefined) {
props.category = data.category as string;
}
if (data.socialLinks !== null && data.socialLinks !== undefined) {
props.socialLinks = data.socialLinks as {
discordUrl?: string;
youtubeUrl?: string;
websiteUrl?: string;
};
}
if (data.logoRef !== null && data.logoRef !== undefined) {
props.logoRef = MediaReference.fromJSON(data.logoRef as Record<string, unknown>);
}
return League.rehydrate(props);
}
}

View File

@@ -5,9 +5,10 @@
* Stores data in a Map structure. * Stores data in a Map structure.
*/ */
import type { Team } from '@core/racing/domain/entities/Team'; import { Team } from '@core/racing/domain/entities/Team';
import type { ITeamRepository } from '@core/racing/domain/repositories/ITeamRepository'; import type { ITeamRepository } from '@core/racing/domain/repositories/ITeamRepository';
import type { Logger } from '@core/shared/application'; import type { Logger } from '@core/shared/application';
import { MediaReference } from '@core/domain/media/MediaReference';
export class InMemoryTeamRepository implements ITeamRepository { export class InMemoryTeamRepository implements ITeamRepository {
private teams: Map<string, Team>; private teams: Map<string, Team>;
@@ -122,4 +123,53 @@ export class InMemoryTeamRepository implements ITeamRepository {
throw error; throw error;
} }
} }
// Serialization methods for persistence
serialize(team: Team): Record<string, unknown> {
return {
id: team.id,
name: team.name.toString(),
tag: team.tag.toString(),
description: team.description.toString(),
ownerId: team.ownerId.toString(),
leagues: team.leagues.map(l => l.toString()),
category: team.category ?? null,
isRecruiting: team.isRecruiting,
createdAt: team.createdAt.toDate().toISOString(),
logoRef: team.logoRef.toJSON(),
};
}
deserialize(data: Record<string, unknown>): Team {
const props: {
id: string;
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
category?: string;
isRecruiting: boolean;
createdAt: Date;
logoRef?: MediaReference;
} = {
id: data.id as string,
name: data.name as string,
tag: data.tag as string,
description: data.description as string,
ownerId: data.ownerId as string,
leagues: data.leagues as string[],
isRecruiting: data.isRecruiting as boolean,
createdAt: new Date(data.createdAt as string),
};
if (data.category !== null && data.category !== undefined) {
props.category = data.category as string;
}
if (data.logoRef !== null && data.logoRef !== undefined) {
props.logoRef = MediaReference.fromJSON(data.logoRef as Record<string, unknown>);
}
return Team.rehydrate(props);
}
} }

View File

@@ -2,7 +2,7 @@
* Infrastructure Adapter: InMemoryMediaRepository * Infrastructure Adapter: InMemoryMediaRepository
* *
* In-memory implementation of IMediaRepository. * In-memory implementation of IMediaRepository.
* Stores URLs for static media assets like logos and images. * Stores URLs for media assets like avatars and logos.
*/ */
import type { IMediaRepository } from '@core/racing/domain/repositories/IMediaRepository'; import type { IMediaRepository } from '@core/racing/domain/repositories/IMediaRepository';
@@ -11,9 +11,8 @@ import type { Logger } from '@core/shared/application';
export class InMemoryMediaRepository implements IMediaRepository { export class InMemoryMediaRepository implements IMediaRepository {
private driverAvatars = new Map<string, string>(); private driverAvatars = new Map<string, string>();
private teamLogos = new Map<string, string>(); private teamLogos = new Map<string, string>();
private trackImages = new Map<string, string>(); private leagueLogos = new Map<string, string>();
private categoryIcons = new Map<string, string>(); private leagueCovers = new Map<string, string>();
private sponsorLogos = new Map<string, string>();
constructor(private readonly logger: Logger) { constructor(private readonly logger: Logger) {
this.logger.info('[InMemoryMediaRepository] Initialized.'); this.logger.info('[InMemoryMediaRepository] Initialized.');
@@ -27,16 +26,12 @@ export class InMemoryMediaRepository implements IMediaRepository {
return this.teamLogos.get(teamId) ?? null; return this.teamLogos.get(teamId) ?? null;
} }
async getTrackImage(trackId: string): Promise<string | null> { async getLeagueLogo(leagueId: string): Promise<string | null> {
return this.trackImages.get(trackId) ?? null; return this.leagueLogos.get(leagueId) ?? null;
} }
async getCategoryIcon(categoryId: string): Promise<string | null> { async getLeagueCover(leagueId: string): Promise<string | null> {
return this.categoryIcons.get(categoryId) ?? null; return this.leagueCovers.get(leagueId) ?? null;
}
async getSponsorLogo(sponsorId: string): Promise<string | null> {
return this.sponsorLogos.get(sponsorId) ?? null;
} }
// Helper methods for seeding // Helper methods for seeding
@@ -48,23 +43,18 @@ export class InMemoryMediaRepository implements IMediaRepository {
this.teamLogos.set(teamId, url); this.teamLogos.set(teamId, url);
} }
setTrackImage(trackId: string, url: string): void { setLeagueLogo(leagueId: string, url: string): void {
this.trackImages.set(trackId, url); this.leagueLogos.set(leagueId, url);
} }
setCategoryIcon(categoryId: string, url: string): void { setLeagueCover(leagueId: string, url: string): void {
this.categoryIcons.set(categoryId, url); this.leagueCovers.set(leagueId, url);
}
setSponsorLogo(sponsorId: string, url: string): void {
this.sponsorLogos.set(sponsorId, url);
} }
async clear(): Promise<void> { async clear(): Promise<void> {
this.driverAvatars.clear(); this.driverAvatars.clear();
this.teamLogos.clear(); this.teamLogos.clear();
this.trackImages.clear(); this.leagueLogos.clear();
this.categoryIcons.clear(); this.leagueCovers.clear();
this.sponsorLogos.clear();
} }
} }

View File

@@ -22,4 +22,7 @@ export class DriverOrmEntity {
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
category!: string | null; category!: string | null;
@Column({ type: 'jsonb', nullable: true })
avatarRef!: Record<string, unknown> | null;
} }

View File

@@ -36,4 +36,7 @@ export class LeagueOrmEntity {
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
websiteUrl!: string | null; websiteUrl!: string | null;
@Column({ type: 'jsonb', nullable: true })
logoRef!: Record<string, unknown> | null;
} }

View File

@@ -28,6 +28,9 @@ export class TeamOrmEntity {
@Column({ type: 'timestamptz' }) @Column({ type: 'timestamptz' })
createdAt!: Date; createdAt!: Date;
@Column({ type: 'jsonb', nullable: true })
logoRef!: Record<string, unknown> | null;
} }
@Entity({ name: 'racing_team_memberships' }) @Entity({ name: 'racing_team_memberships' })

View File

@@ -5,9 +5,6 @@ export class TeamStatsOrmEntity {
@PrimaryColumn({ type: 'uuid' }) @PrimaryColumn({ type: 'uuid' })
teamId!: string; teamId!: string;
@Column({ type: 'text' })
logoUrl!: string;
@Column({ type: 'text' }) @Column({ type: 'text' })
performanceLevel!: string; performanceLevel!: string;

View File

@@ -1,4 +1,5 @@
import { Driver } from '@core/racing/domain/entities/Driver'; import { Driver } from '@core/racing/domain/entities/Driver';
import { MediaReference } from '@core/domain/media/MediaReference';
import { DriverOrmEntity } from '../entities/DriverOrmEntity'; import { DriverOrmEntity } from '../entities/DriverOrmEntity';
import { assertDate, assertNonEmptyString, assertOptionalStringOrNull } from '../schema/TypeOrmSchemaGuards'; import { assertDate, assertNonEmptyString, assertOptionalStringOrNull } from '../schema/TypeOrmSchemaGuards';
@@ -13,6 +14,8 @@ export class DriverOrmMapper {
entity.bio = domain.bio?.toString() ?? null; entity.bio = domain.bio?.toString() ?? null;
entity.joinedAt = domain.joinedAt.toDate(); entity.joinedAt = domain.joinedAt.toDate();
entity.category = domain.category ?? null; entity.category = domain.category ?? null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
entity.avatarRef = domain.avatarRef.toJSON() as any;
return entity; return entity;
} }
@@ -35,6 +38,7 @@ export class DriverOrmMapper {
bio?: string; bio?: string;
joinedAt: Date; joinedAt: Date;
category?: string; category?: string;
avatarRef?: MediaReference;
} = { } = {
id: entity.id, id: entity.id,
iracingId: entity.iracingId, iracingId: entity.iracingId,
@@ -49,6 +53,10 @@ export class DriverOrmMapper {
if (entity.category !== null && entity.category !== undefined) { if (entity.category !== null && entity.category !== undefined) {
props.category = entity.category; props.category = entity.category;
} }
if (entity.avatarRef !== null && entity.avatarRef !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
props.avatarRef = MediaReference.fromJSON(entity.avatarRef as any);
}
return Driver.rehydrate(props); return Driver.rehydrate(props);
} }

View File

@@ -1,4 +1,5 @@
import { League, type LeagueSettings } from '@core/racing/domain/entities/League'; import { League, type LeagueSettings } from '@core/racing/domain/entities/League';
import { MediaReference } from '@core/domain/media/MediaReference';
import { LeagueOrmEntity } from '../entities/LeagueOrmEntity'; import { LeagueOrmEntity } from '../entities/LeagueOrmEntity';
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError'; import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
@@ -161,6 +162,8 @@ export class LeagueOrmMapper {
entity.discordUrl = domain.socialLinks?.discordUrl ?? null; entity.discordUrl = domain.socialLinks?.discordUrl ?? null;
entity.youtubeUrl = domain.socialLinks?.youtubeUrl ?? null; entity.youtubeUrl = domain.socialLinks?.youtubeUrl ?? null;
entity.websiteUrl = domain.socialLinks?.websiteUrl ?? null; entity.websiteUrl = domain.socialLinks?.websiteUrl ?? null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
entity.logoRef = domain.logoRef.toJSON() as any;
return entity; return entity;
} }
@@ -185,6 +188,10 @@ export class LeagueOrmMapper {
}, },
} }
: {}), : {}),
...(entity.logoRef !== null && entity.logoRef !== undefined
// eslint-disable-next-line @typescript-eslint/no-explicit-any
? { logoRef: MediaReference.fromJSON(entity.logoRef as any) }
: {}),
}); });
} }
} }

View File

@@ -1,4 +1,5 @@
import { Team } from '@core/racing/domain/entities/Team'; import { Team } from '@core/racing/domain/entities/Team';
import { MediaReference } from '@core/domain/media/MediaReference';
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError'; import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
@@ -28,6 +29,8 @@ export class TeamOrmMapper {
entity.category = domain.category ?? null; entity.category = domain.category ?? null;
entity.isRecruiting = domain.isRecruiting; entity.isRecruiting = domain.isRecruiting;
entity.createdAt = domain.createdAt.toDate(); entity.createdAt = domain.createdAt.toDate();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
entity.logoRef = domain.logoRef.toJSON() as any;
return entity; return entity;
} }
@@ -57,6 +60,7 @@ export class TeamOrmMapper {
category?: string; category?: string;
isRecruiting: boolean; isRecruiting: boolean;
createdAt: Date; createdAt: Date;
logoRef?: MediaReference;
} = { } = {
id: entity.id, id: entity.id,
name: entity.name, name: entity.name,
@@ -72,6 +76,11 @@ export class TeamOrmMapper {
rehydrateProps.category = entity.category; rehydrateProps.category = entity.category;
} }
if (entity.logoRef !== null && entity.logoRef !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
rehydrateProps.logoRef = MediaReference.fromJSON(entity.logoRef as any);
}
return Team.rehydrate(rehydrateProps); return Team.rehydrate(rehydrateProps);
} catch { } catch {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: '__root', reason: 'invalid_shape' }); throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: '__root', reason: 'invalid_shape' });

View File

@@ -15,7 +15,6 @@ export class TeamStatsOrmMapper {
toOrmEntity(teamId: string, domain: TeamStats): TeamStatsOrmEntity { toOrmEntity(teamId: string, domain: TeamStats): TeamStatsOrmEntity {
const entity = new TeamStatsOrmEntity(); const entity = new TeamStatsOrmEntity();
entity.teamId = teamId; entity.teamId = teamId;
entity.logoUrl = domain.logoUrl;
entity.performanceLevel = domain.performanceLevel; entity.performanceLevel = domain.performanceLevel;
entity.specialization = domain.specialization; entity.specialization = domain.specialization;
entity.region = domain.region; entity.region = domain.region;
@@ -31,7 +30,6 @@ export class TeamStatsOrmMapper {
const entityName = 'TeamStats'; const entityName = 'TeamStats';
assertNonEmptyString(entityName, 'teamId', entity.teamId); assertNonEmptyString(entityName, 'teamId', entity.teamId);
assertNonEmptyString(entityName, 'logoUrl', entity.logoUrl);
assertEnumValue(entityName, 'performanceLevel', entity.performanceLevel, PERFORMANCE_LEVELS); assertEnumValue(entityName, 'performanceLevel', entity.performanceLevel, PERFORMANCE_LEVELS);
assertEnumValue(entityName, 'specialization', entity.specialization, SPECIALIZATIONS); assertEnumValue(entityName, 'specialization', entity.specialization, SPECIALIZATIONS);
assertNonEmptyString(entityName, 'region', entity.region); assertNonEmptyString(entityName, 'region', entity.region);
@@ -41,7 +39,6 @@ export class TeamStatsOrmMapper {
assertInteger(entityName, 'rating', entity.rating); assertInteger(entityName, 'rating', entity.rating);
const result: TeamStats = { const result: TeamStats = {
logoUrl: entity.logoUrl,
performanceLevel: entity.performanceLevel as 'beginner' | 'intermediate' | 'advanced' | 'pro', performanceLevel: entity.performanceLevel as 'beginner' | 'intermediate' | 'advanced' | 'pro',
specialization: entity.specialization as 'endurance' | 'sprint' | 'mixed', specialization: entity.specialization as 'endurance' | 'sprint' | 'mixed',
region: entity.region, region: entity.region,

View File

@@ -4,6 +4,8 @@ import type { DataSource } from 'typeorm';
import { TypeOrmDriverRepository } from './TypeOrmDriverRepository'; import { TypeOrmDriverRepository } from './TypeOrmDriverRepository';
import { DriverOrmMapper } from '../mappers/DriverOrmMapper'; import { DriverOrmMapper } from '../mappers/DriverOrmMapper';
import { Driver } from '@core/racing/domain/entities/Driver';
import { MediaReference } from '@core/domain/media/MediaReference';
describe('TypeOrmDriverRepository', () => { describe('TypeOrmDriverRepository', () => {
it('constructor requires injected mapper (no internal mapper instantiation)', () => { it('constructor requires injected mapper (no internal mapper instantiation)', () => {
@@ -33,4 +35,193 @@ describe('TypeOrmDriverRepository', () => {
await expect(repo.findById('driver-1')).resolves.toBeNull(); await expect(repo.findById('driver-1')).resolves.toBeNull();
}); });
it('persists and retrieves driver with avatarRef (roundtrip test)', async () => {
// Create a driver with a specific avatar reference
const driver = Driver.create({
id: 'driver-123',
iracingId: '456789',
name: 'Test Driver',
country: 'US',
avatarRef: MediaReference.createUploaded('media-abc-123'),
});
// Mock entity that would be saved
const mockEntity = {
id: 'driver-123',
iracingId: '456789',
name: 'Test Driver',
country: 'US',
bio: null,
joinedAt: driver.joinedAt.toDate(),
category: null,
avatarRef: { type: 'uploaded', mediaId: 'media-abc-123' },
};
const savedEntities: any[] = [];
const repo = {
save: async (entity: any) => {
savedEntities.push(entity);
return entity;
},
findOne: async () => savedEntities[0] || null,
};
const mapper = new DriverOrmMapper();
const typeOrmRepo = new TypeOrmDriverRepository(
{ getRepository: () => repo } as unknown as DataSource,
mapper,
);
// Test save
await typeOrmRepo.create(driver);
expect(savedEntities).toHaveLength(1);
expect(savedEntities[0].avatarRef).toEqual({ type: 'uploaded', mediaId: 'media-abc-123' });
// Test load
const loaded = await typeOrmRepo.findById('driver-123');
expect(loaded).not.toBeNull();
expect(loaded!.avatarRef.type).toBe('uploaded');
expect(loaded!.avatarRef.mediaId).toBe('media-abc-123');
});
it('handles system-default avatarRef correctly', async () => {
const driver = Driver.create({
id: 'driver-456',
iracingId: '98765',
name: 'Default Driver',
country: 'UK',
avatarRef: MediaReference.createSystemDefault('avatar'),
});
const mockEntity = {
id: 'driver-456',
iracingId: '98765',
name: 'Default Driver',
country: 'UK',
bio: null,
joinedAt: driver.joinedAt.toDate(),
category: null,
avatarRef: { type: 'system-default', variant: 'avatar' },
};
const savedEntities: any[] = [];
const repo = {
save: async (entity: any) => {
savedEntities.push(entity);
return entity;
},
findOne: async () => savedEntities[0] || null,
};
const mapper = new DriverOrmMapper();
const typeOrmRepo = new TypeOrmDriverRepository(
{ getRepository: () => repo } as unknown as DataSource,
mapper,
);
await typeOrmRepo.create(driver);
expect(savedEntities[0].avatarRef).toEqual({ type: 'system-default', variant: 'avatar' });
const loaded = await typeOrmRepo.findById('driver-456');
expect(loaded!.avatarRef.type).toBe('system-default');
expect(loaded!.avatarRef.variant).toBe('avatar');
});
it('handles generated avatarRef correctly', async () => {
const driver = Driver.create({
id: 'driver-789',
iracingId: '11111',
name: 'Generated Driver',
country: 'DE',
avatarRef: MediaReference.createGenerated('gen-req-xyz'),
});
const mockEntity = {
id: 'driver-789',
iracingId: '11111',
name: 'Generated Driver',
country: 'DE',
bio: null,
joinedAt: driver.joinedAt.toDate(),
category: null,
avatarRef: { type: 'generated', generationRequestId: 'gen-req-xyz' },
};
const savedEntities: any[] = [];
const repo = {
save: async (entity: any) => {
savedEntities.push(entity);
return entity;
},
findOne: async () => savedEntities[0] || null,
};
const mapper = new DriverOrmMapper();
const typeOrmRepo = new TypeOrmDriverRepository(
{ getRepository: () => repo } as unknown as DataSource,
mapper,
);
await typeOrmRepo.create(driver);
expect(savedEntities[0].avatarRef).toEqual({ type: 'generated', generationRequestId: 'gen-req-xyz' });
const loaded = await typeOrmRepo.findById('driver-789');
expect(loaded!.avatarRef.type).toBe('generated');
expect(loaded!.avatarRef.generationRequestId).toBe('gen-req-xyz');
});
it('handles update with changed avatarRef', async () => {
const driver = Driver.create({
id: 'driver-update',
iracingId: '22222',
name: 'Update Driver',
country: 'FR',
avatarRef: MediaReference.createSystemDefault('avatar'),
});
const savedEntities: any[] = [];
const repo = {
save: async (entity: any) => {
savedEntities.push(entity);
return entity;
},
findOne: async () => savedEntities[savedEntities.length - 1] || null,
};
const mapper = new DriverOrmMapper();
const typeOrmRepo = new TypeOrmDriverRepository(
{ getRepository: () => repo } as unknown as DataSource,
mapper,
);
// Initial save
await typeOrmRepo.create(driver);
expect(savedEntities[0].avatarRef).toEqual({ type: 'system-default', variant: 'avatar' });
// Update with new avatar
const updatedDriver = driver.update({
avatarRef: MediaReference.createUploaded('new-media-id'),
});
await typeOrmRepo.update(updatedDriver);
expect(savedEntities).toHaveLength(2);
expect(savedEntities[1].avatarRef).toEqual({ type: 'uploaded', mediaId: 'new-media-id' });
const loaded = await typeOrmRepo.findById('driver-update');
expect(loaded!.avatarRef.type).toBe('uploaded');
expect(loaded!.avatarRef.mediaId).toBe('new-media-id');
});
}); });

View File

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

View File

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

View File

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

View File

@@ -74,21 +74,43 @@ export class BootstrapModule implements OnModuleInit {
} }
private async needsReseed(): Promise<boolean> { private async needsReseed(): Promise<boolean> {
// Check if driver count is less than expected (150) // Check if entity counts are below expected thresholds
// This indicates old seed data that needs updating // This is conflict-proof: only checks durable entity presence, not transient media state
try { 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 drivers = await this.seedDeps.driverRepository.findAll();
const driverCount = drivers.length; const driverCount = drivers.length;
if (driverCount < EXPECTED_DRIVERS) {
// If we have fewer than 150 drivers, we need to reseed this.logger.info(`[Bootstrap] Found ${driverCount} drivers (expected ${EXPECTED_DRIVERS}), triggering reseed`);
if (driverCount < 150) {
this.logger.info(`[Bootstrap] Found ${driverCount} drivers (expected 150), triggering reseed`);
return true; 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; return false;
} catch (error) { } catch (error) {
this.logger.warn('[Bootstrap] Error checking driver count for reseed:', error); this.logger.warn('[Bootstrap] Error checking for reseed:', error);
return false; return false;
} }
} }

View File

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

View File

@@ -14,7 +14,7 @@ export class DashboardFriendSummaryDTO {
@IsString() @IsString()
country!: string; country!: string;
@ApiProperty() @ApiProperty({ nullable: true })
@IsString() @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 { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository'; import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository';
import type { IStandingRepository } from '@core/racing/domain/repositories/IStandingRepository'; import type { IStandingRepository } from '@core/racing/domain/repositories/IStandingRepository';
import type { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
// Import use cases // Import use cases
import { CompleteDriverOnboardingUseCase } from '@core/racing/application/use-cases/CompleteDriverOnboardingUseCase'; 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 new repositories
import { InMemoryDriverStatsRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverStatsRepository'; import { InMemoryDriverStatsRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverStatsRepository';
import { InMemoryMediaRepository } from '@adapters/racing/persistence/media/InMemoryMediaRepository'; import { InMemoryMediaRepository } from '@adapters/racing/persistence/media/InMemoryMediaRepository';
import { MediaResolverAdapter } from '@adapters/media/MediaResolverAdapter';
// Import repository tokens // Import repository tokens
import { IDriverStatsRepository } from '@core/racing/domain/repositories/IDriverStatsRepository'; import { IDriverStatsRepository } from '@core/racing/domain/repositories/IDriverStatsRepository';
import { IMediaRepository } from '@core/racing/domain/repositories/IMediaRepository';
// Import use case interfaces // Import use case interfaces
import type { IRankingUseCase } from '@core/racing/application/use-cases/IRankingUseCase'; import type { IRankingUseCase } from '@core/racing/application/use-cases/IRankingUseCase';
import type { IDriverStatsUseCase } from '@core/racing/application/use-cases/IDriverStatsUseCase'; import type { IDriverStatsUseCase } from '@core/racing/application/use-cases/IDriverStatsUseCase';
@@ -73,6 +74,7 @@ import {
MEDIA_REPOSITORY_TOKEN, MEDIA_REPOSITORY_TOKEN,
RANKING_SERVICE_TOKEN, RANKING_SERVICE_TOKEN,
DRIVER_STATS_SERVICE_TOKEN, DRIVER_STATS_SERVICE_TOKEN,
MEDIA_RESOLVER_TOKEN,
} from './DriverTokens'; } from './DriverTokens';
export * from './DriverTokens'; export * from './DriverTokens';
@@ -80,16 +82,34 @@ export * from './DriverTokens';
export const DriverProviders: Provider[] = [ export const DriverProviders: Provider[] = [
// Presenters // Presenters
DriversLeaderboardPresenter, {
provide: DriversLeaderboardPresenter,
useFactory: (mediaResolver: MediaResolverPort) => {
const presenter = new DriversLeaderboardPresenter();
presenter.setMediaResolver(mediaResolver);
return presenter;
},
inject: [MEDIA_RESOLVER_TOKEN],
},
DriverStatsPresenter, DriverStatsPresenter,
CompleteOnboardingPresenter, CompleteOnboardingPresenter,
DriverRegistrationStatusPresenter, DriverRegistrationStatusPresenter,
{ {
provide: DriverPresenter, provide: DriverPresenter,
useFactory: (driverStatsRepository: IDriverStatsRepository) => new DriverPresenter(driverStatsRepository), useFactory: (driverStatsRepository: IDriverStatsRepository, mediaResolver: MediaResolverPort) => {
inject: [DRIVER_STATS_REPOSITORY_TOKEN], 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) // Output ports (point to presenters)
{ {
@@ -123,6 +143,12 @@ export const DriverProviders: Provider[] = [
useClass: ConsoleLogger, 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) // Repositories (racing + social repos are provided by imported persistence modules)
{ {
provide: DRIVER_STATS_REPOSITORY_TOKEN, provide: DRIVER_STATS_REPOSITORY_TOKEN,
@@ -131,7 +157,22 @@ export const DriverProviders: Provider[] = [
}, },
{ {
provide: MEDIA_REPOSITORY_TOKEN, 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], inject: [LOGGER_TOKEN],
}, },
{ {
@@ -180,21 +221,16 @@ export const DriverProviders: Provider[] = [
driverRepo: IDriverRepository, driverRepo: IDriverRepository,
rankingUseCase: IRankingUseCase, rankingUseCase: IRankingUseCase,
driverStatsUseCase: IDriverStatsUseCase, driverStatsUseCase: IDriverStatsUseCase,
mediaRepository: IMediaRepository,
logger: Logger, logger: Logger,
output: UseCaseOutputPort<unknown>, output: UseCaseOutputPort<unknown>,
) => new GetDriversLeaderboardUseCase( ) => new GetDriversLeaderboardUseCase(
driverRepo, driverRepo,
rankingUseCase, rankingUseCase,
driverStatsUseCase, driverStatsUseCase,
async (driverId: string) => {
const avatar = await mediaRepository.getDriverAvatar(driverId);
return avatar ?? undefined;
},
logger, logger,
output 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, 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 () => { it('getDriversLeaderboard executes use case and returns presenter model', async () => {
const getDriversLeaderboardUseCase = { execute: vi.fn(async () => Result.ok(undefined)) }; 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( const service = new DriverService(
getDriversLeaderboardUseCase as any, getDriversLeaderboardUseCase as any,
@@ -22,7 +33,7 @@ describe('DriverService', () => {
{ getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any, { getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any,
{ getResponseModel: vi.fn(() => ({ success: true })) } as any, { getResponseModel: vi.fn(() => ({ success: true })) } as any,
{ getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any, { getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any,
{ getResponseModel: vi.fn(() => null) } as any, driverPresenter as any,
{ getResponseModel: vi.fn(() => ({ profile: {} })) } as any, { getResponseModel: vi.fn(() => ({ profile: {} })) } as any,
); );
@@ -34,6 +45,12 @@ describe('DriverService', () => {
it('getTotalDrivers executes use case and returns presenter model', async () => { it('getTotalDrivers executes use case and returns presenter model', async () => {
const getTotalDriversUseCase = { execute: vi.fn(async () => Result.ok(undefined)) }; const getTotalDriversUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const driverStatsPresenter = { getResponseModel: vi.fn(() => ({ totalDrivers: 123 })) }; 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( const service = new DriverService(
{ execute: vi.fn() } as any, { execute: vi.fn() } as any,
@@ -48,7 +65,7 @@ describe('DriverService', () => {
driverStatsPresenter as any, driverStatsPresenter as any,
{ getResponseModel: vi.fn(() => ({ success: true })) } as any, { getResponseModel: vi.fn(() => ({ success: true })) } as any,
{ getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any, { getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any,
{ getResponseModel: vi.fn(() => null) } as any, driverPresenter as any,
{ getResponseModel: vi.fn(() => ({ profile: {} })) } as any, { getResponseModel: vi.fn(() => ({ profile: {} })) } as any,
); );
@@ -59,6 +76,12 @@ describe('DriverService', () => {
it('completeOnboarding passes optional bio only when provided', async () => { it('completeOnboarding passes optional bio only when provided', async () => {
const completeDriverOnboardingUseCase = { execute: vi.fn(async () => Result.ok(undefined)) }; 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( const service = new DriverService(
{ execute: vi.fn() } as any, { execute: vi.fn() } as any,
@@ -73,7 +96,7 @@ describe('DriverService', () => {
{ getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any, { getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any,
{ getResponseModel: vi.fn(() => ({ success: true })) } as any, { getResponseModel: vi.fn(() => ({ success: true })) } as any,
{ getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any, { getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any,
{ getResponseModel: vi.fn(() => null) } as any, driverPresenter as any,
{ getResponseModel: vi.fn(() => ({ profile: {} })) } 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 () => { it('getDriverRegistrationStatus passes raceId and driverId and returns presenter model', async () => {
const isDriverRegisteredForRaceUseCase = { execute: vi.fn(async () => Result.ok(undefined)) }; const isDriverRegisteredForRaceUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const driverRegistrationStatusPresenter = { getResponseModel: vi.fn(() => ({ isRegistered: true })) }; 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( const service = new DriverService(
{ execute: vi.fn() } as any, { execute: vi.fn() } as any,
@@ -129,7 +158,7 @@ describe('DriverService', () => {
{ getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any, { getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any,
{ getResponseModel: vi.fn(() => ({ success: true })) } as any, { getResponseModel: vi.fn(() => ({ success: true })) } as any,
driverRegistrationStatusPresenter as any, driverRegistrationStatusPresenter as any,
{ getResponseModel: vi.fn(() => null) } as any, driverPresenter as any,
{ getResponseModel: vi.fn(() => ({ profile: {} })) } as any, { getResponseModel: vi.fn(() => ({ profile: {} })) } as any,
); );
@@ -143,7 +172,12 @@ describe('DriverService', () => {
it('getCurrentDriver calls repository and returns presenter model', async () => { it('getCurrentDriver calls repository and returns presenter model', async () => {
const driverRepository = { findById: vi.fn(async () => null) }; 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( const service = new DriverService(
{ execute: vi.fn() } as any, { execute: vi.fn() } as any,
@@ -169,7 +203,12 @@ describe('DriverService', () => {
it('updateDriverProfile builds optional input and returns presenter model', async () => { it('updateDriverProfile builds optional input and returns presenter model', async () => {
const updateDriverProfileUseCase = { execute: vi.fn(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( const service = new DriverService(
{ execute: vi.fn() } as any, { execute: vi.fn() } as any,
@@ -211,7 +250,12 @@ describe('DriverService', () => {
it('getDriver calls repository and returns presenter model', async () => { it('getDriver calls repository and returns presenter model', async () => {
const driverRepository = { findById: vi.fn(async () => null) }; 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( const service = new DriverService(
{ execute: vi.fn() } as any, { execute: vi.fn() } as any,
@@ -237,7 +281,17 @@ describe('DriverService', () => {
it('getDriverProfile executes use case and returns presenter model', async () => { it('getDriverProfile executes use case and returns presenter model', async () => {
const getProfileOverviewUseCase = { execute: vi.fn(async () => Result.ok(undefined)) }; 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( const service = new DriverService(
{ execute: vi.fn() } as any, { execute: vi.fn() } as any,
@@ -252,7 +306,7 @@ describe('DriverService', () => {
{ getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any, { getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any,
{ getResponseModel: vi.fn(() => ({ success: true })) } as any, { getResponseModel: vi.fn(() => ({ success: true })) } as any,
{ getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any, { getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any,
{ getResponseModel: vi.fn(() => null) } as any, driverPresenter as any,
driverProfilePresenter as any, driverProfilePresenter as any,
); );

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,4 +36,7 @@ export class GetDriverOutputDTO {
@ApiProperty({ required: false }) @ApiProperty({ required: false })
totalRaces?: number; 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 { Driver } from '@core/racing/domain/entities/Driver';
import type { GetDriverOutputDTO } from '../dtos/GetDriverOutputDTO'; import type { GetDriverOutputDTO } from '../dtos/GetDriverOutputDTO';
import type { IDriverStatsRepository } from '@core/racing/domain/repositories/IDriverStatsRepository'; 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 { export class DriverPresenter {
private responseModel: GetDriverOutputDTO | null = null; private responseModel: GetDriverOutputDTO | null = null;
private mediaResolver: MediaResolverPort | undefined;
constructor( 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 // 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()) { if (result.isErr()) {
const error = result.unwrapErr(); const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Failed to get driver'); 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) // Get stats from repository (synchronously for now, could be async)
const stats = this.driverStatsRepository.getDriverStatsSync(driver.id); 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, id: driver.id,
iracingId: driver.iracingId.toString(), iracingId: driver.iracingId.toString(),
name: driver.name.toString(), name: driver.name.toString(),
country: driver.country.toString(), country: driver.country.toString(),
joinedAt: driver.joinedAt.toDate().toISOString(), joinedAt: driver.joinedAt.toDate().toISOString(),
avatarUrl,
...(driver.bio ? { bio: driver.bio.toString() } : {}), ...(driver.bio ? { bio: driver.bio.toString() } : {}),
...(driver.category ? { category: driver.category } : {}), ...(driver.category ? { category: driver.category } : {}),
// Add stats fields // Add stats fields
@@ -43,6 +63,8 @@ export class DriverPresenter {
experienceLevel: this.getExperienceLevel(stats.rating), experienceLevel: this.getExperienceLevel(stats.rating),
} : {}), } : {}),
}; };
this.responseModel = dto;
} }
getResponseModel(): GetDriverOutputDTO | null { getResponseModel(): GetDriverOutputDTO | null {
@@ -55,4 +77,4 @@ export class DriverPresenter {
if (rating >= 1000) return 'intermediate'; if (rating >= 1000) return 'intermediate';
return 'beginner'; return 'beginner';
} }
} }

View File

@@ -3,19 +3,49 @@ import type {
} from '@core/racing/application/use-cases/GetProfileOverviewUseCase'; } from '@core/racing/application/use-cases/GetProfileOverviewUseCase';
import type { GetDriverProfileOutputDTO } from '../dtos/GetDriverProfileOutputDTO'; import type { GetDriverProfileOutputDTO } from '../dtos/GetDriverProfileOutputDTO';
import type { DriverProfileExtendedProfileDTO } from '../dtos/DriverProfileExtendedProfileDTO'; 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 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 = { this.responseModel = {
currentDriver: result.driverInfo currentDriver: result.driverInfo
? { ? {
id: result.driverInfo.driver.id, id: result.driverInfo.driver.id,
name: result.driverInfo.driver.name.toString(), name: result.driverInfo.driver.name.toString(),
country: result.driverInfo.driver.country.toString(), country: result.driverInfo.driver.country.toString(),
avatarUrl: this.getAvatarUrl(result.driverInfo.driver.id) || '', avatarUrl: currentDriverAvatarUrl,
iracingId: result.driverInfo.driver.iracingId.toString(), iracingId: result.driverInfo.driver.iracingId.toString(),
joinedAt: result.driverInfo.driver.joinedAt.toDate().toISOString(), joinedAt: result.driverInfo.driver.joinedAt.toDate().toISOString(),
category: result.driverInfo.driver.category || null, category: result.driverInfo.driver.category || null,
@@ -42,7 +72,7 @@ export class DriverProfilePresenter
id: friend.id, id: friend.id,
name: friend.name.toString(), name: friend.name.toString(),
country: friend.country.toString(), country: friend.country.toString(),
avatarUrl: '', // TODO: get avatar avatarUrl: friendAvatars[friend.id] ?? null,
})), })),
}, },
extendedProfile: result.extendedProfile as DriverProfileExtendedProfileDTO | null, extendedProfile: result.extendedProfile as DriverProfileExtendedProfileDTO | null,
@@ -53,11 +83,4 @@ export class DriverProfilePresenter
if (!this.responseModel) throw new Error('Presenter not presented'); if (!this.responseModel) throw new Error('Presenter not presented');
return this.responseModel; 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 { 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 { DriversLeaderboardPresenter } from './DriversLeaderboardPresenter';
import type { Driver } from '@core/racing/domain/entities/Driver'; import type { Driver } from '@core/racing/domain/entities/Driver';
import type { SkillLevel } from '@core/racing/domain/services/SkillLevelService'; import type { SkillLevel } from '@core/racing/domain/services/SkillLevelService';
import { MediaReference } from '@core/domain/media/MediaReference';
// TODO fix eslint issues import type { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
describe('DriversLeaderboardPresenter', () => { describe('DriversLeaderboardPresenter', () => {
let presenter: DriversLeaderboardPresenter; let presenter: DriversLeaderboardPresenter;
let mockResolver: MediaResolverPort;
beforeEach(() => { 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 = new DriversLeaderboardPresenter();
presenter.setMediaResolver(mockResolver);
}); });
describe('present', () => { 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 = { const coreResult: GetDriversLeaderboardResult = {
items: [ items: [
{ {
@@ -30,7 +63,7 @@ describe('DriversLeaderboardPresenter', () => {
podiums: 20, podiums: 20,
isActive: true, isActive: true,
rank: 1, rank: 1,
avatarUrl: 'https://example.com/avatar1.png', avatarRef: MediaReference.createUploaded('avatar-1'),
}, },
{ {
driver: { driver: {
@@ -45,7 +78,7 @@ describe('DriversLeaderboardPresenter', () => {
podiums: 15, podiums: 15,
isActive: true, isActive: true,
rank: 2, rank: 2,
avatarUrl: 'https://example.com/avatar2.png', avatarRef: MediaReference.createGenerated('driver-2'),
}, },
], ],
totalRaces: 90, totalRaces: 90,
@@ -53,7 +86,7 @@ describe('DriversLeaderboardPresenter', () => {
activeCount: 2, activeCount: 2,
}; };
presenter.present(coreResult); await presenter.present(coreResult);
const output = presenter.getResponseModel(); const output = presenter.getResponseModel();
@@ -69,7 +102,7 @@ describe('DriversLeaderboardPresenter', () => {
podiums: 20, podiums: 20,
isActive: true, isActive: true,
rank: 1, rank: 1,
avatarUrl: 'https://example.com/avatar1.png', avatarUrl: '/media/uploaded/avatar-1',
}); });
expect(output.drivers[1]).toEqual({ expect(output.drivers[1]).toEqual({
id: 'driver-2', id: 'driver-2',
@@ -82,12 +115,75 @@ describe('DriversLeaderboardPresenter', () => {
podiums: 15, podiums: 15,
isActive: true, isActive: true,
rank: 2, rank: 2,
avatarUrl: 'https://example.com/avatar2.png', avatarUrl: '/media/avatar/2',
}); });
expect(output.totalRaces).toBe(90); expect(output.totalRaces).toBe(90);
expect(output.totalWins).toBe(15); expect(output.totalWins).toBe(15);
expect(output.activeCount).toBe(2); 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 { import type {
GetDriversLeaderboardResult, GetDriversLeaderboardResult,
} from '@core/racing/application/use-cases/GetDriversLeaderboardUseCase'; } 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 { export class DriversLeaderboardPresenter {
private responseModel: DriversLeaderboardDTO | null = null; 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 = { this.responseModel = {
drivers: data.items.map(item => ({ drivers,
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 } : {}),
})),
totalRaces: data.totalRaces, totalRaces: data.totalRaces,
totalWins: data.totalWins, totalWins: data.totalWins,
activeCount: data.activeCount, 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 { ISeasonSponsorshipRepository } from '@core/racing/domain/repositories/ISeasonSponsorshipRepository';
import type { IStandingRepository } from '@core/racing/domain/repositories/IStandingRepository'; import type { IStandingRepository } from '@core/racing/domain/repositories/IStandingRepository';
import type { Logger } from '@core/shared/application/Logger'; import type { Logger } from '@core/shared/application/Logger';
import type { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
// Import concrete in-memory implementations // Import concrete in-memory implementations
import type { ILeagueWalletRepository } from "@core/racing/domain/repositories/ILeagueWalletRepository"; 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 { getLeagueScoringPresetById, listLeagueScoringPresets } from '@adapters/bootstrap/LeagueScoringPresets';
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger'; import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
import { InMemoryLeagueStandingsRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueStandingsRepository'; import { InMemoryLeagueStandingsRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueStandingsRepository';
import { MediaResolverAdapter } from '@adapters/media/MediaResolverAdapter';
// Import use cases // Import use cases
import { ApproveLeagueJoinRequestUseCase } from '@core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase'; 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 LEAGUE_WALLET_REPOSITORY_TOKEN = 'ILeagueWalletRepository';
export const TRANSACTION_REPOSITORY_TOKEN = 'ITransactionRepository'; export const TRANSACTION_REPOSITORY_TOKEN = 'ITransactionRepository';
export const LOGGER_TOKEN = 'Logger'; export const LOGGER_TOKEN = 'Logger';
export const MEDIA_RESOLVER_TOKEN = 'MediaResolverPort';
export const GET_LEAGUE_STANDINGS_USE_CASE = 'GetLeagueStandingsUseCase'; 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_USE_CASE = 'GetAllLeaguesWithCapacityUseCase';
export const GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_USE_CASE = 'GetAllLeaguesWithCapacityAndScoringUseCase'; export const GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_USE_CASE = 'GetAllLeaguesWithCapacityAndScoringUseCase';
@@ -177,9 +181,21 @@ export const LeagueProviders: Provider[] = [
provide: LOGGER_TOKEN, provide: LOGGER_TOKEN,
useClass: ConsoleLogger, useClass: ConsoleLogger,
}, },
{
provide: MEDIA_RESOLVER_TOKEN,
useFactory: () => new MediaResolverAdapter({}),
},
// Presenters // Presenters
AllLeaguesWithCapacityPresenter, AllLeaguesWithCapacityPresenter,
AllLeaguesWithCapacityAndScoringPresenter, {
provide: AllLeaguesWithCapacityAndScoringPresenter,
useFactory: (mediaResolver: MediaResolverPort) => {
const presenter = new AllLeaguesWithCapacityAndScoringPresenter();
presenter.setMediaResolver(mediaResolver);
return presenter;
},
inject: [MEDIA_RESOLVER_TOKEN],
},
ApproveLeagueJoinRequestPresenter, ApproveLeagueJoinRequestPresenter,
CreateLeaguePresenter, CreateLeaguePresenter,
GetLeagueAdminPermissionsPresenter, GetLeagueAdminPermissionsPresenter,

View File

@@ -319,6 +319,9 @@ export class LeagueService {
throw new Error(err.code); 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(); 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 LEAGUE_WALLET_REPOSITORY_TOKEN = 'ILeagueWalletRepository';
export const TRANSACTION_REPOSITORY_TOKEN = 'ITransactionRepository'; export const TRANSACTION_REPOSITORY_TOKEN = 'ITransactionRepository';
export const LOGGER_TOKEN = 'Logger'; export const LOGGER_TOKEN = 'Logger';
export const MEDIA_RESOLVER_TOKEN = 'MediaResolverPort';
export const GET_LEAGUE_STANDINGS_USE_CASE = 'GetLeagueStandingsUseCase'; 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_USE_CASE = 'GetAllLeaguesWithCapacityUseCase';

View File

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

View File

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

View File

@@ -4,52 +4,92 @@ import type {
AllLeaguesWithCapacityAndScoringDTO, AllLeaguesWithCapacityAndScoringDTO,
LeagueWithCapacityAndScoringDTO, LeagueWithCapacityAndScoringDTO,
} from '../dtos/AllLeaguesWithCapacityAndScoringDTO'; } from '../dtos/AllLeaguesWithCapacityAndScoringDTO';
import type { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
import { MediaReference } from '@core/domain/media/MediaReference';
export class AllLeaguesWithCapacityAndScoringPresenter export class AllLeaguesWithCapacityAndScoringPresenter
implements UseCaseOutputPort<GetAllLeaguesWithCapacityAndScoringResult> implements UseCaseOutputPort<GetAllLeaguesWithCapacityAndScoringResult>
{ {
private result: AllLeaguesWithCapacityAndScoringDTO | null = null; private result: AllLeaguesWithCapacityAndScoringDTO | null = null;
private mediaResolver?: MediaResolverPort;
present(result: GetAllLeaguesWithCapacityAndScoringResult): void { setMediaResolver(resolver: MediaResolverPort): void {
const leagues: LeagueWithCapacityAndScoringDTO[] = result.leagues.map((summary) => { this.mediaResolver = resolver;
const timingSummary = summary.preset }
? formatTimingSummary(summary.preset.defaultTimings.mainRaceMinutes)
: undefined;
return { async present(result: GetAllLeaguesWithCapacityAndScoringResult): Promise<void> {
id: summary.league.id.toString(), const leagues: LeagueWithCapacityAndScoringDTO[] = await Promise.all(
name: summary.league.name.toString(), result.leagues.map(async (summary) => {
description: summary.league.description?.toString() || '', const timingSummary = summary.preset
ownerId: summary.league.ownerId.toString(), ? formatTimingSummary(summary.preset.defaultTimings.mainRaceMinutes)
createdAt: summary.league.createdAt.toDate().toISOString(), : undefined;
settings: {
maxDrivers: summary.maxDrivers, // Resolve logo URL
...(summary.league.settings.sessionDuration !== undefined let logoUrl: string | null | undefined;
? { sessionDuration: summary.league.settings.sessionDuration } if (summary.league.logoRef) {
: {}), const ref = summary.league.logoRef instanceof MediaReference
...(summary.league.settings.qualifyingFormat !== undefined ? summary.league.logoRef
? { qualifyingFormat: summary.league.settings.qualifyingFormat.toString() } : MediaReference.fromJSON(summary.league.logoRef);
: {}),
}, if (this.mediaResolver) {
usedSlots: summary.currentDrivers, logoUrl = await this.mediaResolver.resolve(ref);
...(summary.league.category ? { category: summary.league.category } : {}), } else {
...mapSocialLinks(summary.league.socialLinks), // Fallback to manual construction
...(summary.scoringConfig && summary.game && summary.preset if (ref.type === 'generated' && ref.generationRequestId) {
? { const requestId = ref.generationRequestId;
scoring: { const firstHyphenIndex = requestId.indexOf('-');
gameId: summary.game.id.toString(), if (firstHyphenIndex !== -1) {
gameName: summary.game.name.toString(), const type = requestId.substring(0, firstHyphenIndex);
primaryChampionshipType: summary.preset.primaryChampionshipType, const id = requestId.substring(firstHyphenIndex + 1);
scoringPresetId: summary.scoringConfig.scoringPresetId?.toString() ?? 'custom',
scoringPresetName: summary.preset.name, if (type === 'league') {
dropPolicySummary: summary.preset.dropPolicySummary, logoUrl = `/media/leagues/${id}/logo`;
scoringPatternSummary: summary.preset.sessionSummary, }
}, }
} 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 } : {}), ...(timingSummary ? { timingSummary } : {}),
}; ...(logoUrl !== undefined ? { logoUrl } : {}),
}); };
})
);
this.result = { this.result = {
leagues, 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 { UpdateAvatarOutputDTO } from './dtos/UpdateAvatarOutputDTO';
import { UpdateAvatarInputDTO } from './dtos/UpdateAvatarInputDTO'; import { UpdateAvatarInputDTO } from './dtos/UpdateAvatarInputDTO';
import type { MulterFile } from './types/MulterFile'; 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', () => { describe('MediaController', () => {
let controller: MediaController; let controller: MediaController;
@@ -33,6 +36,13 @@ describe('MediaController', () => {
getAvatar: ReturnType<typeof vi.fn>; getAvatar: ReturnType<typeof vi.fn>;
updateAvatar: 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 () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
@@ -49,24 +59,54 @@ describe('MediaController', () => {
updateAvatar: vi.fn(), 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(); }).compile();
controller = module.get<MediaController>(MediaController); controller = module.get<MediaController>(MediaController);
service = module.get(MediaService) as MediaService & { service = module.get(MediaService) as any;
requestAvatarGeneration: ReturnType<typeof vi.fn>; generationService = module.get(MediaGenerationService) as any;
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>;
};
}); });
const createMockResponse = (): Response => { const createMockResponse = (): Response => {
const res: Partial<Response> = {}; const res: Partial<Response> = {};
res.status = vi.fn().mockReturnValue(res as Response); res.status = vi.fn().mockReturnValue(res as Response);
res.json = 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; 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', () => { describe('getMedia', () => {
it('should return media if found', async () => { it('should return media if found', async () => {
const mediaId = 'media-123'; const mediaId = 'media-123';
@@ -208,7 +518,7 @@ describe('MediaController', () => {
}); });
}); });
describe('getAvatar', () => { describe('getAvatarDetails', () => {
it('should return avatar if found', async () => { it('should return avatar if found', async () => {
const driverId = 'driver-123'; const driverId = 'driver-123';
const dto: GetAvatarOutputDTO = { const dto: GetAvatarOutputDTO = {
@@ -218,7 +528,7 @@ describe('MediaController', () => {
const res = createMockResponse(); const res = createMockResponse();
await controller.getAvatar(driverId, res); await controller.getAvatarDetails(driverId, res);
expect(service.getAvatar).toHaveBeenCalledWith(driverId); expect(service.getAvatar).toHaveBeenCalledWith(driverId);
expect(res.status).toHaveBeenCalledWith(200); expect(res.status).toHaveBeenCalledWith(200);
@@ -231,7 +541,7 @@ describe('MediaController', () => {
const res = createMockResponse(); const res = createMockResponse();
await controller.getAvatar(driverId, res); await controller.getAvatarDetails(driverId, res);
expect(service.getAvatar).toHaveBeenCalledWith(driverId); expect(service.getAvatar).toHaveBeenCalledWith(driverId);
expect(res.status).toHaveBeenCalledWith(404); expect(res.status).toHaveBeenCalledWith(404);
@@ -289,6 +599,55 @@ describe('MediaController', () => {
useValue: { useValue: {
getMedia: vi.fn(async () => ({ id: 'm1' })), getMedia: vi.fn(async () => ({ id: 'm1' })),
deleteMedia: vi.fn(async () => ({ success: true })), 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); 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 { ValidateFaceInputDTO } from './dtos/ValidateFaceInputDTO';
import { ValidateFaceOutputDTO } from './dtos/ValidateFaceOutputDTO'; import { ValidateFaceOutputDTO } from './dtos/ValidateFaceOutputDTO';
import type { MulterFile } from './types/MulterFile'; 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 RequestAvatarGenerationInput = RequestAvatarGenerationInputDTO;
type UploadMediaInput = UploadMediaInputDTO; type UploadMediaInput = UploadMediaInputDTO;
type UpdateAvatarInput = UpdateAvatarInputDTO; 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') @ApiTags('media')
@Controller('media') @Controller('media')
export class MediaController { 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') @Post('avatar/generate')
@ApiOperation({ summary: 'Request avatar generation' }) @ApiOperation({ summary: 'Request avatar generation' })
@@ -167,11 +47,14 @@ export class MediaController {
@Body() input: RequestAvatarGenerationInput, @Body() input: RequestAvatarGenerationInput,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
this.logger.debug('[MediaController] Requesting avatar generation', { input });
const dto: RequestAvatarGenerationOutputDTO = await this.mediaService.requestAvatarGeneration(input); const dto: RequestAvatarGenerationOutputDTO = await this.mediaService.requestAvatarGeneration(input);
if (dto.success) { if (dto.success) {
this.logger.info('[MediaController] Avatar generation request successful', { dto });
res.status(HttpStatus.CREATED).json(dto); res.status(HttpStatus.CREATED).json(dto);
} else { } else {
this.logger.warn('[MediaController] Avatar generation request failed', { dto });
res.status(HttpStatus.BAD_REQUEST).json(dto); res.status(HttpStatus.BAD_REQUEST).json(dto);
} }
} }
@@ -186,167 +69,306 @@ export class MediaController {
@Body() input: UploadMediaInput, @Body() input: UploadMediaInput,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
this.logger.debug('[MediaController] Uploading media', { filename: file?.originalname, input });
const dto: UploadMediaOutputDTO = await this.mediaService.uploadMedia({ ...input, file }); const dto: UploadMediaOutputDTO = await this.mediaService.uploadMedia({ ...input, file });
if (dto.success) { if (dto.success) {
this.logger.info('[MediaController] Media upload successful', { mediaId: dto.mediaId });
res.status(HttpStatus.CREATED).json(dto); res.status(HttpStatus.CREATED).json(dto);
} else { } else {
this.logger.warn('[MediaController] Media upload failed', { error: dto.error });
res.status(HttpStatus.BAD_REQUEST).json(dto); 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() @Public()
@Get('teams/:teamId/logo') @Get('teams/:teamId/logo')
@ApiOperation({ summary: 'Get team logo (placeholder)' }) @ApiOperation({ summary: 'Get team logo (dynamically generated)' })
@ApiParam({ name: 'teamId', description: 'Team ID' }) @ApiParam({ name: 'teamId', description: 'Team ID' })
async getTeamLogo( async getTeamLogo(
@Param('teamId') teamId: string, @Param('teamId') teamId: string,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): 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('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400'); res.setHeader('Cache-Control', 'public, max-age=86400');
res.status(HttpStatus.OK).send(svg); res.status(HttpStatus.OK).send(svg);
this.logger.info('[MediaController] Team logo generated', { teamId, svgLength });
} }
@Public() @Public()
@Get('team/:teamId/logo') @Get('leagues/:leagueId/logo')
@ApiOperation({ summary: 'Get team logo (singular path)' }) @ApiOperation({ summary: 'Get league logo (dynamically generated)' })
@ApiParam({ name: 'teamId', description: 'Team ID' }) @ApiParam({ name: 'leagueId', description: 'League ID' })
async getTeamLogoSingular( async getLeagueLogo(
@Param('teamId') teamId: string, @Param('leagueId') leagueId: string,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): 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('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400'); res.setHeader('Cache-Control', 'public, max-age=86400');
res.status(HttpStatus.OK).send(svg); res.status(HttpStatus.OK).send(svg);
this.logger.info('[MediaController] League logo generated', { leagueId, svgLength });
} }
@Public() @Public()
@Get('team/:teamId/logo.png') @Get('leagues/:leagueId/cover')
@ApiOperation({ summary: 'Get team logo with .png extension' }) @ApiOperation({ summary: 'Get league cover (dynamically generated)' })
@ApiParam({ name: 'teamId', description: 'Team ID' }) @ApiParam({ name: 'leagueId', description: 'League ID' })
async getTeamLogoPng( async getLeagueCover(
@Param('teamId') teamId: string, @Param('leagueId') leagueId: string,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): 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('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400'); res.setHeader('Cache-Control', 'public, max-age=86400');
res.status(HttpStatus.OK).send(svg); res.status(HttpStatus.OK).send(svg);
this.logger.info('[MediaController] League cover generated', { leagueId, svgLength });
} }
@Public() @Public()
@Get('teams/:teamId/cover') @Get('avatar/:driverId')
@ApiOperation({ summary: 'Get team cover (placeholder)' }) @ApiOperation({ summary: 'Get driver avatar (dynamically generated)' })
@ApiParam({ name: 'teamId', description: 'Team ID' })
async getTeamCover(
@Param('teamId') teamId: string,
@Res() res: Response,
): Promise<void> {
const svg = buildLeagueCoverSvg(teamId);
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.status(HttpStatus.OK).send(svg);
}
@Public()
@Get('drivers/:driverId/avatar')
@ApiOperation({ summary: 'Get driver avatar (placeholder)' })
@ApiParam({ name: 'driverId', description: 'Driver ID' }) @ApiParam({ name: 'driverId', description: 'Driver ID' })
async getDriverAvatar( async getDriverAvatar(
@Param('driverId') driverId: string, @Param('driverId') driverId: string,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): 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('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400'); res.setHeader('Cache-Control', 'public, max-age=86400');
res.status(HttpStatus.OK).send(svg); res.status(HttpStatus.OK).send(svg);
this.logger.info('[MediaController] Driver avatar generated', { driverId, svgLength });
} }
@Public() @Public()
@Get('avatar/:driverId') @Get('default/:variant')
@ApiOperation({ summary: 'Get driver avatar (alternative path)' }) @ApiOperation({ summary: 'Get default media asset (PNG)' })
@ApiParam({ name: 'driverId', description: 'Driver ID' }) @ApiParam({ name: 'variant', description: 'Variant name (e.g., male-default-avatar, female-default-avatar, logo)' })
async getDriverAvatarAlt( async getDefaultMedia(
@Param('driverId') driverId: string, @Param('variant') variant: string,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
const svg = buildDriverAvatarSvg(driverId); this.logger.debug('[MediaController] Getting default media', { variant });
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400'); // Drivers must use real assets from website public dir.
res.status(HttpStatus.OK).send(svg); // 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() @Public()
@Get('tracks/:trackId/image') @Get('generated/:type/:id')
@ApiOperation({ summary: 'Get track image (placeholder)' }) @ApiOperation({ summary: 'Get generated media (SVG)' })
@ApiParam({ name: 'trackId', description: 'Track ID' }) @ApiParam({ name: 'type', description: 'Media type (team, league, driver)' })
async getTrackImage( @ApiParam({ name: 'id', description: 'Entity ID' })
@Param('trackId') trackId: string, async getGeneratedMedia(
@Param('type') type: string,
@Param('id') id: string,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): 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('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); res.status(HttpStatus.OK).send(svg);
this.logger.info('[MediaController] Generated media served', { type, id, svgLength });
} }
@Public() @Public()
@Get('categories/:categoryId/icon') @Get('uploaded/:mediaId')
@ApiOperation({ summary: 'Get category icon (placeholder)' }) @ApiOperation({ summary: 'Get uploaded media' })
@ApiParam({ name: 'categoryId', description: 'Category ID' }) @ApiParam({ name: 'mediaId', description: 'Media ID' })
async getCategoryIcon( async getUploadedMedia(
@Param('categoryId') categoryId: string, @Param('mediaId') mediaId: string,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
const svg = buildCategoryIconSvg(categoryId); this.logger.debug('[MediaController] Getting uploaded media', { mediaId });
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400'); // Look up the media to get the storage key
res.status(HttpStatus.OK).send(svg); 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() @Public()
@Get('sponsors/:sponsorId/logo') @Get('debug/resolve')
@ApiOperation({ summary: 'Get sponsor logo (placeholder)' }) @ApiOperation({ summary: 'Debug media reference resolution' })
@ApiParam({ name: 'sponsorId', description: 'Sponsor ID' }) @ApiResponse({ status: 200, description: 'Resolution debug info' })
async getSponsorLogo( async debugResolve(
@Param('sponsorId') sponsorId: string,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
const svg = buildLeagueLogoSvg(sponsorId); this.logger.debug('[MediaController] Debug resolve requested');
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400'); // Parse query parameters
res.status(HttpStatus.OK).send(svg); 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() @Public()
@@ -358,11 +380,14 @@ export class MediaController {
@Param('mediaId') mediaId: string, @Param('mediaId') mediaId: string,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
this.logger.debug('[MediaController] Getting media details', { mediaId });
const dto: GetMediaOutputDTO | null = await this.mediaService.getMedia(mediaId); const dto: GetMediaOutputDTO | null = await this.mediaService.getMedia(mediaId);
if (dto) { if (dto) {
this.logger.info('[MediaController] Media details found', { mediaId });
res.status(HttpStatus.OK).json(dto); res.status(HttpStatus.OK).json(dto);
} else { } else {
this.logger.warn('[MediaController] Media not found', { mediaId });
res.status(HttpStatus.NOT_FOUND).json({ error: 'Media not found' }); res.status(HttpStatus.NOT_FOUND).json({ error: 'Media not found' });
} }
} }
@@ -375,25 +400,30 @@ export class MediaController {
@Param('mediaId') mediaId: string, @Param('mediaId') mediaId: string,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
this.logger.debug('[MediaController] Deleting media', { mediaId });
const dto: DeleteMediaOutputDTO = await this.mediaService.deleteMedia(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); res.status(HttpStatus.OK).json(dto);
} }
@Public() @Public()
@Get('avatar/:driverId') @Get('avatar/:driverId/details')
@ApiOperation({ summary: 'Get avatar for driver' }) @ApiOperation({ summary: 'Get avatar details for driver' })
@ApiParam({ name: 'driverId', description: 'Driver ID' }) @ApiParam({ name: 'driverId', description: 'Driver ID' })
@ApiResponse({ status: 200, description: 'Avatar details', type: GetAvatarOutputDTO }) @ApiResponse({ status: 200, description: 'Avatar details', type: GetAvatarOutputDTO })
async getAvatar( async getAvatarDetails(
@Param('driverId') driverId: string, @Param('driverId') driverId: string,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
this.logger.debug('[MediaController] Getting avatar details', { driverId });
const dto: GetAvatarOutputDTO | null = await this.mediaService.getAvatar(driverId); const dto: GetAvatarOutputDTO | null = await this.mediaService.getAvatar(driverId);
if (dto) { if (dto) {
this.logger.info('[MediaController] Avatar details found', { driverId });
res.status(HttpStatus.OK).json(dto); res.status(HttpStatus.OK).json(dto);
} else { } else {
this.logger.warn('[MediaController] Avatar not found', { driverId });
res.status(HttpStatus.NOT_FOUND).json({ error: 'Avatar not found' }); res.status(HttpStatus.NOT_FOUND).json({ error: 'Avatar not found' });
} }
} }
@@ -407,8 +437,10 @@ export class MediaController {
@Body() input: UpdateAvatarInput, @Body() input: UpdateAvatarInput,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
this.logger.debug('[MediaController] Updating avatar', { driverId });
const dto: UpdateAvatarOutputDTO = await this.mediaService.updateAvatar(driverId, input); 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); res.status(HttpStatus.OK).json(dto);
} }
@@ -419,12 +451,15 @@ export class MediaController {
@Body() input: ValidateFaceInputDTO, @Body() input: ValidateFaceInputDTO,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
this.logger.debug('[MediaController] Validating face photo');
const dto: ValidateFaceOutputDTO = await this.mediaService.validateFacePhoto(input); const dto: ValidateFaceOutputDTO = await this.mediaService.validateFacePhoto(input);
if (dto.isValid) { if (dto.isValid) {
this.logger.info('[MediaController] Face validation passed');
res.status(HttpStatus.OK).json(dto); res.status(HttpStatus.OK).json(dto);
} else { } else {
this.logger.warn('[MediaController] Face validation failed', { errorMessage: dto.errorMessage });
res.status(HttpStatus.BAD_REQUEST).json(dto); 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 { FaceValidationResult } from '@core/media/application/ports/FaceValidationPort';
import type { AvatarGenerationResult } from '@core/media/application/ports/AvatarGenerationPort'; 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 // External adapters (ports) - these remain mock implementations
class MockFaceValidationAdapter implements FaceValidationPort { 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 { class MockLogger implements Logger {
debug(): void {} debug(): void {}
info(): void {} info(): void {}
@@ -104,7 +92,16 @@ class MockLogger implements Logger {
error(): void {} 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[] = [ export const MediaProviders: Provider[] = [
MediaGenerationService,
{
provide: MediaResolverAdapter,
useFactory: () => new MediaResolverAdapter({}),
},
RequestAvatarGenerationPresenter, RequestAvatarGenerationPresenter,
UploadMediaPresenter, UploadMediaPresenter,
GetMediaPresenter, GetMediaPresenter,
@@ -121,7 +118,9 @@ export const MediaProviders: Provider[] = [
}, },
{ {
provide: MEDIA_STORAGE_PORT_TOKEN, provide: MEDIA_STORAGE_PORT_TOKEN,
useClass: MockMediaStorageAdapter, useFactory: () => new FileSystemMediaStorageAdapter({
baseDir: process.env.MEDIA_STORAGE_DIR || '/data/media',
}),
}, },
{ {
provide: LOGGER_TOKEN, provide: LOGGER_TOKEN,

View File

@@ -166,6 +166,16 @@ export class MediaService {
async updateAvatar(driverId: string, input: UpdateAvatarInput): Promise<UpdateAvatarOutputDTO> { async updateAvatar(driverId: string, input: UpdateAvatarInput): Promise<UpdateAvatarOutputDTO> {
this.logger.debug(`[MediaService] Updating avatar for driver: ${driverId}`); 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({ const result = await this.updateAvatarUseCase.execute({
driverId, driverId,
mediaUrl: input.avatarUrl, mediaUrl: input.avatarUrl,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { Provider } from '@nestjs/common'; 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 { export {
TEAM_REPOSITORY_TOKEN, TEAM_REPOSITORY_TOKEN,
@@ -9,15 +9,18 @@ export {
IMAGE_SERVICE_TOKEN, IMAGE_SERVICE_TOKEN,
LOGGER_TOKEN, LOGGER_TOKEN,
MEDIA_REPOSITORY_TOKEN, MEDIA_REPOSITORY_TOKEN,
MEDIA_RESOLVER_TOKEN,
} from './TeamTokens'; } from './TeamTokens';
// Import core interfaces // Import core interfaces
import type { Logger } from '@core/shared/application/Logger'; import type { Logger } from '@core/shared/application/Logger';
import type { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
// Import concrete in-memory implementations // Import concrete implementations
import { InMemoryImageServiceAdapter } from '@adapters/media/ports/InMemoryImageServiceAdapter'; import { InMemoryImageServiceAdapter } from '@adapters/media/ports/InMemoryImageServiceAdapter';
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger'; import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
import { InMemoryMediaRepository } from '@adapters/racing/persistence/media/InMemoryMediaRepository'; import { InMemoryMediaRepository } from '@adapters/racing/persistence/media/InMemoryMediaRepository';
import { MediaResolverAdapter } from '@adapters/media/MediaResolverAdapter';
// Import presenters // Import presenters
import { AllTeamsPresenter } from './presenters/AllTeamsPresenter'; import { AllTeamsPresenter } from './presenters/AllTeamsPresenter';
@@ -34,11 +37,36 @@ export const TeamProviders: Provider[] = [
}, },
{ {
provide: MEDIA_REPOSITORY_TOKEN, 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], inject: [LOGGER_TOKEN],
}, },
{ {
provide: AllTeamsPresenter, provide: MEDIA_RESOLVER_TOKEN,
useFactory: () => new AllTeamsPresenter(), 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; ownerId: ValueObjectStub;
leagues: ValueObjectStub[]; leagues: ValueObjectStub[];
createdAt: { toDate(): Date }; createdAt: { toDate(): Date };
logoRef: any;
category: string | undefined;
isRecruiting: boolean;
update: Mock; update: Mock;
}; };
@@ -43,6 +46,9 @@ describe('TeamService', () => {
ownerId: makeValueObject('owner-1'), ownerId: makeValueObject('owner-1'),
leagues: [makeValueObject('league-1')], leagues: [makeValueObject('league-1')],
createdAt: { toDate: () => new Date('2023-01-01T00:00:00.000Z') }, createdAt: { toDate: () => new Date('2023-01-01T00:00:00.000Z') },
logoRef: { type: 'system-default', variant: 'logo' },
category: undefined,
isRecruiting: false,
}; };
const team: TeamEntityStub = { const team: TeamEntityStub = {
@@ -95,7 +101,7 @@ describe('TeamService', () => {
countByTeamId: vi.fn(), countByTeamId: vi.fn(),
getActiveMembershipForDriver: vi.fn(), getActiveMembershipForDriver: vi.fn(),
getMembership: vi.fn(), getMembership: vi.fn(),
getTeamMembers: vi.fn(), getTeamMembers: vi.fn().mockResolvedValue([]),
getJoinRequests: vi.fn(), getJoinRequests: vi.fn(),
saveMembership: vi.fn(), saveMembership: vi.fn(),
}; };
@@ -112,28 +118,38 @@ describe('TeamService', () => {
} as unknown as Logger; } as unknown as Logger;
const teamStatsRepository = { const teamStatsRepository = {
getTeamStats: vi.fn(), getTeamStats: vi.fn().mockResolvedValue(undefined),
saveTeamStats: vi.fn(), saveTeamStats: vi.fn(),
getAllStats: vi.fn(), getAllStats: vi.fn(),
clear: vi.fn(), clear: vi.fn(),
}; };
const mediaRepository = {
getTeamAvatar: vi.fn(),
saveTeamAvatar: vi.fn(),
getDriverAvatar: vi.fn(),
saveDriverAvatar: vi.fn(),
};
const resultRepository = { const resultRepository = {
findAll: vi.fn(), findAll: vi.fn().mockResolvedValue([]),
}; };
// Mock presenter that stores result synchronously
const allTeamsPresenter = { const allTeamsPresenter = {
reset: vi.fn(), reset: vi.fn(),
present: vi.fn(), present: vi.fn((result: any) => {
getResponseModel: vi.fn(() => ({ teams: [], totalCount: 0 })), // 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 }, responseModel: { teams: [], totalCount: 0 },
setMediaResolver: vi.fn(),
setBaseUrl: vi.fn(),
}; };
service = new TeamService( service = new TeamService(
@@ -142,9 +158,8 @@ describe('TeamService', () => {
driverRepository as unknown as never, driverRepository as unknown as never,
logger, logger,
teamStatsRepository as unknown as never, teamStatsRepository as unknown as never,
mediaRepository as unknown as never,
resultRepository 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()]); teamRepository.findAll.mockResolvedValue([makeTeam()]);
membershipRepository.countByTeamId.mockResolvedValue(3); membershipRepository.countByTeamId.mockResolvedValue(3);
await expect(service.getAll()).resolves.toEqual({ const result = await service.getAll();
await expect(result).toEqual({
teams: [ teams: [
{ {
id: 'team-1', id: 'team-1',
@@ -161,6 +178,7 @@ describe('TeamService', () => {
description: 'Desc', description: 'Desc',
memberCount: 3, memberCount: 3,
leagues: ['league-1'], leagues: ['league-1'],
logoUrl: null,
}, },
], ],
totalCount: 1, totalCount: 1,
@@ -200,6 +218,8 @@ describe('TeamService', () => {
description: 'Desc', description: 'Desc',
ownerId: 'owner-1', ownerId: 'owner-1',
leagues: ['league-1'], leagues: ['league-1'],
category: undefined,
isRecruiting: false,
createdAt: '2023-01-01T00:00:00.000Z', createdAt: '2023-01-01T00:00:00.000Z',
}, },
membership: { membership: {
@@ -503,6 +523,8 @@ describe('TeamService', () => {
description: 'Desc', description: 'Desc',
ownerId: 'owner-1', ownerId: 'owner-1',
leagues: ['league-1'], leagues: ['league-1'],
category: undefined,
isRecruiting: false,
createdAt: '2023-01-01T00:00:00.000Z', createdAt: '2023-01-01T00:00:00.000Z',
}, },
membership: { membership: {

View File

@@ -37,9 +37,8 @@ import { CreateTeamPresenter } from './presenters/CreateTeamPresenter';
import { UpdateTeamPresenter } from './presenters/UpdateTeamPresenter'; import { UpdateTeamPresenter } from './presenters/UpdateTeamPresenter';
// Tokens // Tokens
import { TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN, TEAM_STATS_REPOSITORY_TOKEN, MEDIA_REPOSITORY_TOKEN, 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 { 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'; import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository';
@Injectable() @Injectable()
@@ -50,7 +49,6 @@ export class TeamService {
@Inject(DRIVER_REPOSITORY_TOKEN) private readonly driverRepository: IDriverRepository, @Inject(DRIVER_REPOSITORY_TOKEN) private readonly driverRepository: IDriverRepository,
@Inject(LOGGER_TOKEN) private readonly logger: Logger, @Inject(LOGGER_TOKEN) private readonly logger: Logger,
@Inject(TEAM_STATS_REPOSITORY_TOKEN) private readonly teamStatsRepository: ITeamStatsRepository, @Inject(TEAM_STATS_REPOSITORY_TOKEN) private readonly teamStatsRepository: ITeamStatsRepository,
@Inject(MEDIA_REPOSITORY_TOKEN) private readonly mediaRepository: IMediaRepository,
@Inject(RESULT_REPOSITORY_TOKEN) private readonly resultRepository: IResultRepository, @Inject(RESULT_REPOSITORY_TOKEN) private readonly resultRepository: IResultRepository,
private readonly allTeamsPresenter: AllTeamsPresenter, private readonly allTeamsPresenter: AllTeamsPresenter,
) {} ) {}
@@ -62,7 +60,6 @@ export class TeamService {
this.teamRepository, this.teamRepository,
this.membershipRepository, this.membershipRepository,
this.teamStatsRepository, this.teamStatsRepository,
this.mediaRepository,
this.resultRepository, this.resultRepository,
this.logger, this.logger,
this.allTeamsPresenter this.allTeamsPresenter

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ export class TeamMemberDTO {
@ApiProperty() @ApiProperty()
driverName!: string; driverName!: string;
@ApiProperty({ enum: ['owner', 'manager', 'member'] }) @ApiProperty()
role!: 'owner' | 'manager' | 'member'; role!: 'owner' | 'manager' | 'member';
@ApiProperty() @ApiProperty()
@@ -16,7 +16,6 @@ export class TeamMemberDTO {
@ApiProperty() @ApiProperty()
isActive!: boolean; isActive!: boolean;
@ApiProperty() @ApiProperty({ nullable: true })
avatarUrl!: string; 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 type { GetAllTeamsResult } from '@core/racing/application/use-cases/GetAllTeamsUseCase';
import { GetAllTeamsOutputDTO } from '../dtos/GetAllTeamsOutputDTO'; import { GetAllTeamsOutputDTO } from '../dtos/GetAllTeamsOutputDTO';
import { TeamListItemDTO } from '../dtos/TeamListItemDTO'; 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> { export class AllTeamsPresenter implements UseCaseOutputPort<GetAllTeamsResult> {
private model: GetAllTeamsOutputDTO | null = null; private model: GetAllTeamsOutputDTO | null = null;
private mediaResolver?: MediaResolverPort;
setMediaResolver(resolver: MediaResolverPort): void {
this.mediaResolver = resolver;
}
reset(): void { reset(): void {
this.model = null; this.model = null;
} }
present(result: GetAllTeamsResult): void { async present(result: GetAllTeamsResult): Promise<void> {
const teams: TeamListItemDTO[] = result.teams.map(team => { const teams: TeamListItemDTO[] = await Promise.all(
const dto = new TeamListItemDTO(); result.teams.map(async (team) => {
dto.id = team.id; const dto = new TeamListItemDTO();
dto.name = team.name; dto.id = team.id;
dto.tag = team.tag; dto.name = team.name;
dto.description = team.description || ''; dto.tag = team.tag;
dto.memberCount = team.memberCount; dto.description = team.description || '';
dto.leagues = team.leagues || []; dto.memberCount = team.memberCount;
dto.totalWins = team.totalWins ?? 0; dto.leagues = team.leagues || [];
dto.totalRaces = team.totalRaces ?? 0; dto.totalWins = team.totalWins ?? 0;
dto.performanceLevel = (team.performanceLevel as 'beginner' | 'intermediate' | 'advanced' | 'pro') ?? 'intermediate'; dto.totalRaces = team.totalRaces ?? 0;
dto.specialization = (team.specialization as 'endurance' | 'sprint' | 'mixed') ?? 'mixed'; dto.performanceLevel = (team.performanceLevel as 'beginner' | 'intermediate' | 'advanced' | 'pro') ?? 'intermediate';
dto.region = team.region ?? ''; dto.specialization = (team.specialization as 'endurance' | 'sprint' | 'mixed') ?? 'mixed';
dto.languages = team.languages ?? []; dto.region = team.region ?? '';
// Return relative URL for proxying through Next.js rewrites dto.languages = team.languages ?? [];
dto.logoUrl = `/api/media/teams/${team.id}/logo`;
dto.rating = team.rating ?? 0; // Resolve logo URL using MediaResolverPort if available
dto.category = team.category; if (this.mediaResolver && team.logoRef) {
dto.isRecruiting = team.isRecruiting; const ref = team.logoRef instanceof MediaReference ? team.logoRef : MediaReference.fromJSON(team.logoRef);
return dto; 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 = { this.model = {
teams, teams,

View File

@@ -22,6 +22,7 @@ interface OpenAPISchema {
enum?: string[]; enum?: string[];
nullable?: boolean; nullable?: boolean;
description?: string; description?: string;
default?: unknown;
} }
interface OpenAPISpec { 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 Breadcrumbs from '@/components/layout/Breadcrumbs';
import { useServices } from '@/lib/services/ServiceProvider'; import { useServices } from '@/lib/services/ServiceProvider';
import { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel'; import { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel';
import { mediaConfig } from '@/lib/config/mediaConfig';
// ============================================================================ // ============================================================================
// TYPES // 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-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"> <div className="w-full h-full rounded-xl overflow-hidden bg-iron-gray">
<Image <Image
src={driver.avatarUrl} src={driver.avatarUrl || mediaConfig.avatars.defaultFallback}
alt={driver.name} alt={driver.name}
width={144} width={144}
height={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"> <div className="w-8 h-8 rounded-full overflow-hidden bg-gradient-to-br from-primary-blue to-purple-600">
<Image <Image
src={friend.avatarUrl || '/default-avatar.png'} src={friend.avatarUrl || mediaConfig.avatars.defaultFallback}
alt={friend.name} alt={friend.name}
width={32} width={32}
height={32} height={32}

View File

@@ -22,6 +22,7 @@ import Card from '@/components/ui/Card';
import Heading from '@/components/ui/Heading'; import Heading from '@/components/ui/Heading';
import { useDriverLeaderboard } from '@/hooks/useDriverService'; import { useDriverLeaderboard } from '@/hooks/useDriverService';
import Image from 'next/image'; import Image from 'next/image';
import { mediaConfig } from '@/lib/config/mediaConfig';
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel'; import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
@@ -133,7 +134,7 @@ function FeaturedDriverCard({ driver, position, onClick }: FeaturedDriverCardPro
{/* Avatar & Name */} {/* Avatar & Name */}
<div className="flex items-center gap-4 mb-4"> <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"> <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>
<div> <div>
<h3 className="text-lg font-semibold text-white group-hover:text-primary-blue transition-colors"> <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 */} {/* Avatar */}
<div className="relative w-9 h-9 rounded-full overflow-hidden border-2 border-charcoal-outline"> <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> </div>
{/* Info */} {/* 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" 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"> <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 className="absolute bottom-0 right-0 w-3 h-3 rounded-full bg-performance-green border-2 border-iron-gray" />
</div> </div>
<p className="text-sm font-medium text-white truncate group-hover:text-performance-green transition-colors"> <p className="text-sm font-medium text-white truncate group-hover:text-performance-green transition-colors">

View File

@@ -30,7 +30,7 @@ export default function LeagueStandingsPage() {
try { try {
const vm = await leagueService.getLeagueStandings(leagueId, currentDriverId); const vm = await leagueService.getLeagueStandings(leagueId, currentDriverId);
setStandings(vm.standings); 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); setMemberships(vm.memberships);
// Check if current user is admin // 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 { redirect } from 'next/navigation';
import Image from 'next/image';
import { getAppMode } from '@/lib/mode'; import { getAppMode } from '@/lib/mode';
import Hero from '@/components/landing/Hero'; import Hero from '@/components/landing/Hero';
@@ -16,6 +17,7 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { ServiceFactory } from '@/lib/services/ServiceFactory'; import { ServiceFactory } from '@/lib/services/ServiceFactory';
import { getMediaUrl } from '@/lib/utilities/media';
export default async function HomePage() { export default async function HomePage() {
const baseUrl = getWebsiteApiBaseUrl(); const baseUrl = getWebsiteApiBaseUrl();
@@ -299,8 +301,14 @@ export default async function HomePage() {
<ul className="space-y-3 text-sm"> <ul className="space-y-3 text-sm">
{teams.slice(0, 4).map(team => ( {teams.slice(0, 4).map(team => (
<li key={team.id} className="flex items-start gap-3"> <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"> <div className="w-10 h-10 rounded-md bg-charcoal-outline flex items-center justify-center overflow-hidden border border-charcoal-outline">
{team.tag} <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>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-white truncate">{team.name}</p> <p className="text-white truncate">{team.name}</p>

View File

@@ -13,6 +13,7 @@ import type {
DriverProfileSocialHandleViewModel, DriverProfileSocialHandleViewModel,
DriverProfileViewModel DriverProfileViewModel
} from '@/lib/view-models/DriverProfileViewModel'; } from '@/lib/view-models/DriverProfileViewModel';
import { getMediaUrl } from '@/lib/utilities/media';
import { import {
Activity, Activity,
Award, 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-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"> <div className="w-full h-full rounded-xl overflow-hidden bg-iron-gray">
<Image <Image
src={mediaService.getDriverAvatar(currentDriver.id)} src={getMediaUrl('driver-avatar', currentDriver.id)}
alt={currentDriver.name} alt={currentDriver.name}
width={144} width={144}
height={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"> <div className="w-8 h-8 rounded-full overflow-hidden bg-gradient-to-br from-primary-blue to-purple-600">
<Image <Image
src={mediaService.getDriverAvatar(friend.id)} src={getMediaUrl('driver-avatar', friend.id)}
alt={friend.name} alt={friend.name}
width={32} width={32}
height={32} height={32}

View File

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

View File

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

View File

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

View File

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

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