harden media
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 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.
|
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
|
||||||
|
|||||||
39
adapters/bootstrap/MediaAssetConfig.ts
Normal file
39
adapters/bootstrap/MediaAssetConfig.ts
Normal 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;
|
||||||
123
adapters/bootstrap/SeedRacingData.forceReseed.test.ts
Normal file
123
adapters/bootstrap/SeedRacingData.forceReseed.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
82
adapters/bootstrap/racing/RacingDriverFactory.test.ts
Normal file
82
adapters/bootstrap/racing/RacingDriverFactory.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
53
adapters/bootstrap/racing/RacingTeamFactory.test.ts
Normal file
53
adapters/bootstrap/racing/RacingTeamFactory.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
229
adapters/media/MediaResolverAdapter.test.ts
Normal file
229
adapters/media/MediaResolverAdapter.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
127
adapters/media/MediaResolverAdapter.ts
Normal file
127
adapters/media/MediaResolverAdapter.ts
Normal 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({})
|
||||||
|
};
|
||||||
229
adapters/media/MediaResolverInMemoryAdapter.ts
Normal file
229
adapters/media/MediaResolverInMemoryAdapter.ts
Normal 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;
|
||||||
166
adapters/media/ports/FileSystemMediaStorageAdapter.ts
Normal file
166
adapters/media/ports/FileSystemMediaStorageAdapter.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
79
adapters/media/resolvers/DefaultMediaResolverAdapter.ts
Normal file
79
adapters/media/resolvers/DefaultMediaResolverAdapter.ts
Normal 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);
|
||||||
|
}
|
||||||
92
adapters/media/resolvers/GeneratedMediaResolverAdapter.ts
Normal file
92
adapters/media/resolvers/GeneratedMediaResolverAdapter.ts
Normal 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);
|
||||||
|
}
|
||||||
71
adapters/media/resolvers/UploadedMediaResolverAdapter.ts
Normal file
71
adapters/media/resolvers/UploadedMediaResolverAdapter.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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' })
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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' });
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export class DashboardFriendSummaryDTO {
|
|||||||
@IsString()
|
@IsString()
|
||||||
country!: string;
|
country!: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty({ nullable: true })
|
||||||
@IsString()
|
@IsString()
|
||||||
avatarUrl!: string;
|
avatarUrl!: string | null;
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -35,5 +35,5 @@ export class DriverLeaderboardItemDTO {
|
|||||||
rank!: number;
|
rank!: number;
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
@ApiProperty({ nullable: true })
|
||||||
avatarUrl?: string;
|
avatarUrl!: string | null;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ export class DriverProfileSocialFriendSummaryDTO {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
country!: string;
|
country!: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty({ nullable: true })
|
||||||
avatarUrl!: string;
|
avatarUrl!: string | null;
|
||||||
}
|
}
|
||||||
@@ -36,4 +36,7 @@ export class GetDriverOutputDTO {
|
|||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
totalRaces?: number;
|
totalRaces?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
avatarUrl!: string | null;
|
||||||
}
|
}
|
||||||
@@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
48
apps/api/src/domain/media/DefaultAvatarAssets.http.test.ts
Normal file
48
apps/api/src/domain/media/DefaultAvatarAssets.http.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export class DashboardFriendSummaryDTO {
|
|||||||
@IsString()
|
@IsString()
|
||||||
country!: string;
|
country!: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty({ nullable: true })
|
||||||
@IsString()
|
@IsString()
|
||||||
avatarUrl!: string;
|
avatarUrl!: string | null;
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
37
apps/website/app/media/avatar/[driverId]/route.ts
Normal file
37
apps/website/app/media/avatar/[driverId]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
32
apps/website/app/media/categories/[categoryId]/icon/route.ts
Normal file
32
apps/website/app/media/categories/[categoryId]/icon/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
32
apps/website/app/media/leagues/[leagueId]/cover/route.ts
Normal file
32
apps/website/app/media/leagues/[leagueId]/cover/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
32
apps/website/app/media/leagues/[leagueId]/logo/route.ts
Normal file
32
apps/website/app/media/leagues/[leagueId]/logo/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
32
apps/website/app/media/sponsors/[sponsorId]/logo/route.ts
Normal file
32
apps/website/app/media/sponsors/[sponsorId]/logo/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
38
apps/website/app/media/teams/[teamId]/logo/route.ts
Normal file
38
apps/website/app/media/teams/[teamId]/logo/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
32
apps/website/app/media/tracks/[trackId]/image/route.ts
Normal file
32
apps/website/app/media/tracks/[trackId]/image/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user