This commit is contained in:
2025-12-21 19:53:22 +01:00
parent f2d8a23583
commit 3c64f328e2
105 changed files with 3191 additions and 1706 deletions

View File

@@ -23,6 +23,14 @@ import { UpdateDriverProfileUseCase } from '@core/racing/application/use-cases/U
import { IsDriverRegisteredForRaceUseCase } from '@core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase';
import { GetProfileOverviewUseCase } from '@core/racing/application/use-cases/GetProfileOverviewUseCase';
// Import presenters
import { DriverStatsPresenter } from './presenters/DriverStatsPresenter';
import { DriversLeaderboardPresenter } from './presenters/DriversLeaderboardPresenter';
import { CompleteOnboardingPresenter } from './presenters/CompleteOnboardingPresenter';
import { DriverRegistrationStatusPresenter } from './presenters/DriverRegistrationStatusPresenter';
import { DriverPresenter } from './presenters/DriverPresenter';
import { DriverProfilePresenter } from './presenters/DriverProfilePresenter';
// Import concrete in-memory implementations
import { InMemoryDriverRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverRepository';
import { InMemoryRankingService } from '@adapters/racing/services/InMemoryRankingService';

View File

@@ -1,6 +1,12 @@
import { Injectable, Inject } from '@nestjs/common';
import { CompleteOnboardingInputDTO } from './dtos/CompleteOnboardingInputDTO';
import { GetDriverRegistrationStatusQueryDTO } from './dtos/GetDriverRegistrationStatusQueryDTO';
import { DriversLeaderboardDTO } from './dtos/DriversLeaderboardDTO';
import { DriverStatsDTO } from './dtos/DriverStatsDTO';
import { CompleteOnboardingOutputDTO } from './dtos/CompleteOnboardingOutputDTO';
import { DriverRegistrationStatusDTO } from './dtos/DriverRegistrationStatusDTO';
import { GetDriverOutputDTO } from './dtos/GetDriverOutputDTO';
import { GetDriverProfileOutputDTO } from './dtos/GetDriverProfileOutputDTO';
// Use cases
import { GetDriversLeaderboardUseCase } from '@core/racing/application/use-cases/GetDriversLeaderboardUseCase';
@@ -51,37 +57,42 @@ export class DriverService {
private readonly driverRepository: IDriverRepository,
@Inject(LOGGER_TOKEN)
private readonly logger: Logger,
private readonly driversLeaderboardPresenter: DriversLeaderboardPresenter,
private readonly driverStatsPresenter: DriverStatsPresenter,
private readonly completeOnboardingPresenter: CompleteOnboardingPresenter,
private readonly driverRegistrationStatusPresenter: DriverRegistrationStatusPresenter,
private readonly driverPresenter: DriverPresenter,
private readonly driverProfilePresenter: DriverProfilePresenter,
) {}
async getDriversLeaderboard(): Promise<DriversLeaderboardPresenter> {
async getDriversLeaderboard(): Promise<DriversLeaderboardDTO> {
this.logger.debug('[DriverService] Fetching drivers leaderboard.');
const result = await this.getDriversLeaderboardUseCase.execute();
if (result.isErr()) {
throw new Error(`Failed to fetch drivers leaderboard: ${result.unwrapErr().details.message}`);
}
const result = await this.getDriversLeaderboardUseCase.execute({});
const presenter = new DriversLeaderboardPresenter();
presenter.reset();
presenter.present(result.unwrap());
return presenter;
presenter.present(result);
return presenter.getResponseModel();
}
async getTotalDrivers(): Promise<DriverStatsPresenter> {
async getTotalDrivers(): Promise<DriverStatsDTO> {
this.logger.debug('[DriverService] Fetching total drivers count.');
const result = await this.getTotalDriversUseCase.execute();
const result = await this.getTotalDriversUseCase.execute({});
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Failed to load driver stats');
}
const presenter = new DriverStatsPresenter();
presenter.reset();
presenter.present(result.unwrap());
return presenter;
return this.driverStatsPresenter.getResponseModel();
}
async completeOnboarding(userId: string, input: CompleteOnboardingInputDTO): Promise<CompleteOnboardingPresenter> {
async completeOnboarding(
userId: string,
input: CompleteOnboardingInputDTO,
): Promise<CompleteOnboardingOutputDTO> {
this.logger.debug('Completing onboarding for user:', userId);
const result = await this.completeDriverOnboardingUseCase.execute({
@@ -95,20 +106,14 @@ export class DriverService {
});
const presenter = new CompleteOnboardingPresenter();
presenter.reset();
presenter.present(result);
if (result.isOk()) {
presenter.present(result.value);
} else {
presenter.presentError(result.error.code);
}
return presenter;
return presenter.responseModel;
}
async getDriverRegistrationStatus(
query: GetDriverRegistrationStatusQueryDTO,
): Promise<DriverRegistrationStatusPresenter> {
): Promise<DriverRegistrationStatusDTO> {
this.logger.debug('Checking driver registration status:', query);
const result = await this.isDriverRegisteredForRaceUseCase.execute({
@@ -116,77 +121,64 @@ export class DriverService {
driverId: query.driverId,
});
if (result.isErr()) {
throw new Error(`Failed to check registration status: ${result.unwrapErr().code}`);
}
const presenter = new DriverRegistrationStatusPresenter();
presenter.reset();
presenter.present(result);
const output = result.unwrap();
presenter.present(output.isRegistered, output.raceId, output.driverId);
return presenter;
return presenter.responseModel;
}
async getCurrentDriver(userId: string): Promise<DriverPresenter> {
async getCurrentDriver(userId: string): Promise<GetDriverOutputDTO | null> {
this.logger.debug(`[DriverService] Fetching current driver for userId: ${userId}`);
const driver = await this.driverRepository.findById(userId);
const presenter = new DriverPresenter();
presenter.reset();
presenter.present(driver ?? null);
return presenter;
return presenter.responseModel;
}
async updateDriverProfile(
driverId: string,
bio?: string,
country?: string,
): Promise<DriverPresenter> {
): Promise<GetDriverOutputDTO | null> {
this.logger.debug(`[DriverService] Updating driver profile for driverId: ${driverId}`);
const result = await this.updateDriverProfileUseCase.execute({ driverId, bio, country });
const presenter = new DriverPresenter();
presenter.reset();
if (result.isErr()) {
this.logger.error(`Failed to update driver profile: ${result.error.code}`);
presenter.present(null);
return presenter;
return presenter.responseModel;
}
presenter.present(result.value);
return presenter;
const updatedDriver = await this.driverRepository.findById(driverId);
presenter.present(updatedDriver ?? null);
return presenter.responseModel;
}
async getDriver(driverId: string): Promise<DriverPresenter> {
async getDriver(driverId: string): Promise<GetDriverOutputDTO | null> {
this.logger.debug(`[DriverService] Fetching driver for driverId: ${driverId}`);
const driver = await this.driverRepository.findById(driverId);
const presenter = new DriverPresenter();
presenter.reset();
presenter.present(driver ?? null);
return presenter;
return presenter.responseModel;
}
async getDriverProfile(driverId: string): Promise<DriverProfilePresenter> {
async getDriverProfile(driverId: string): Promise<GetDriverProfileOutputDTO> {
this.logger.debug(`[DriverService] Fetching driver profile for driverId: ${driverId}`);
const result = await this.getProfileOverviewUseCase.execute({ driverId });
if (result.isErr()) {
throw new Error(`Failed to fetch driver profile: ${result.error.code}`);
}
const presenter = new DriverProfilePresenter();
presenter.reset();
presenter.present(result.value);
presenter.present(result);
return presenter;
return presenter.responseModel;
}
}

View File

@@ -1,29 +1,23 @@
import type { CompleteDriverOnboardingOutputPort } from '@core/racing/application/ports/output/CompleteDriverOnboardingOutputPort';
import type {
CompleteDriverOnboardingResult,
} from '@core/racing/application/use-cases/CompleteDriverOnboardingUseCase';
import type { CompleteOnboardingOutputDTO } from '../dtos/CompleteOnboardingOutputDTO';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
export class CompleteOnboardingPresenter {
private result: CompleteOnboardingOutputDTO | null = null;
export class CompleteOnboardingPresenter
implements UseCaseOutputPort<CompleteDriverOnboardingResult>
{
private responseModel: CompleteOnboardingOutputDTO | null = null;
reset(): void {
this.result = null;
}
present(output: CompleteDriverOnboardingOutputPort): void {
this.result = {
present(result: CompleteDriverOnboardingResult): void {
this.responseModel = {
success: true,
driverId: output.driverId,
driverId: result.driver.id,
};
}
presentError(errorCode: string): void {
this.result = {
success: false,
errorMessage: errorCode,
};
}
get viewModel(): CompleteOnboardingOutputDTO {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
getResponseModel(): CompleteOnboardingOutputDTO {
if (!this.responseModel) throw new Error('Presenter not presented');
return this.responseModel;
}
}

View File

@@ -2,19 +2,15 @@ import type { Driver } from '@core/racing/domain/entities/Driver';
import type { GetDriverOutputDTO } from '../dtos/GetDriverOutputDTO';
export class DriverPresenter {
private result: GetDriverOutputDTO | null = null;
reset(): void {
this.result = null;
}
private responseModel: GetDriverOutputDTO | null = null;
present(driver: Driver | null): void {
if (!driver) {
this.result = null;
this.responseModel = null;
return;
}
this.result = {
this.responseModel = {
id: driver.id,
iracingId: driver.iracingId.toString(),
name: driver.name.toString(),
@@ -24,7 +20,7 @@ export class DriverPresenter {
};
}
get viewModel(): GetDriverOutputDTO | null {
return this.result;
getResponseModel(): GetDriverOutputDTO | null {
return this.responseModel;
}
}

View File

@@ -1,47 +1,61 @@
import type { ProfileOverviewOutputPort } from '@core/racing/application/ports/output/ProfileOverviewOutputPort';
import type {
GetProfileOverviewResult,
} from '@core/racing/application/use-cases/GetProfileOverviewUseCase';
import type { GetDriverProfileOutputDTO } from '../dtos/GetDriverProfileOutputDTO';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
export class DriverProfilePresenter {
private result: GetDriverProfileOutputDTO | null = null;
export class DriverProfilePresenter
implements UseCaseOutputPort<GetProfileOverviewResult>
{
private responseModel: GetDriverProfileOutputDTO | null = null;
reset(): void {
this.result = null;
}
present(output: ProfileOverviewOutputPort): void {
this.result = {
currentDriver: output.driver
present(result: GetProfileOverviewResult): void {
this.responseModel = {
currentDriver: result.driverInfo
? {
id: output.driver.id,
name: output.driver.name,
country: output.driver.country,
avatarUrl: output.driver.avatarUrl,
iracingId: output.driver.iracingId,
joinedAt: output.driver.joinedAt.toISOString(),
rating: output.driver.rating,
globalRank: output.driver.globalRank,
consistency: output.driver.consistency,
bio: output.driver.bio,
totalDrivers: output.driver.totalDrivers,
id: result.driverInfo.driver.id,
name: result.driverInfo.driver.name.toString(),
country: result.driverInfo.driver.country.toString(),
avatarUrl: this.getAvatarUrl(result.driverInfo.driver.id) || '',
iracingId: result.driverInfo.driver.iracingId.toString(),
joinedAt: result.driverInfo.driver.joinedAt.toDate().toISOString(),
rating: result.driverInfo.rating,
globalRank: result.driverInfo.globalRank,
consistency: result.driverInfo.consistency,
bio: result.driverInfo.driver.bio?.toString() || null,
totalDrivers: result.driverInfo.totalDrivers,
}
: null,
stats: output.stats,
finishDistribution: output.finishDistribution,
teamMemberships: output.teamMemberships.map(membership => ({
teamId: membership.teamId,
teamName: membership.teamName,
teamTag: membership.teamTag,
role: membership.role,
joinedAt: membership.joinedAt.toISOString(),
isCurrent: membership.isCurrent,
stats: result.stats,
finishDistribution: result.finishDistribution,
teamMemberships: result.teamMemberships.map(membership => ({
teamId: membership.team.id,
teamName: membership.team.name.toString(),
teamTag: membership.team.tag.toString(),
role: membership.membership.role,
joinedAt: membership.membership.joinedAt.toISOString(),
isCurrent: true, // TODO: check membership status
})),
socialSummary: output.socialSummary,
extendedProfile: output.extendedProfile,
socialSummary: {
friendsCount: result.socialSummary.friendsCount,
friends: result.socialSummary.friends.map(friend => ({
id: friend.id,
name: friend.name.toString(),
country: friend.country.toString(),
avatarUrl: '', // TODO: get avatar
})),
},
extendedProfile: result.extendedProfile as any,
};
}
get viewModel(): GetDriverProfileOutputDTO {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
getResponseModel(): GetDriverProfileOutputDTO {
if (!this.responseModel) throw new Error('Presenter not presented');
return this.responseModel;
}
private getAvatarUrl(driverId: string): string | undefined {
// Avatar resolution is delegated to infrastructure; keep as-is for now.
return undefined;
}
}

View File

@@ -1,25 +1,24 @@
import type {
IsDriverRegisteredForRaceResult,
} from '@core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase';
import { DriverRegistrationStatusDTO } from '../dtos/DriverRegistrationStatusDTO';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
export class DriverRegistrationStatusPresenter {
private result: DriverRegistrationStatusDTO | null = null;
export class DriverRegistrationStatusPresenter
implements UseCaseOutputPort<IsDriverRegisteredForRaceResult>
{
private responseModel: DriverRegistrationStatusDTO | null = null;
reset(): void {
this.result = null;
}
present(isRegistered: boolean, raceId: string, driverId: string): void {
this.result = {
isRegistered,
raceId,
driverId,
present(result: IsDriverRegisteredForRaceResult): void {
this.responseModel = {
isRegistered: result.isRegistered,
raceId: result.raceId,
driverId: result.driverId,
};
}
get viewModel(): DriverRegistrationStatusDTO {
if (!this.result) {
throw new Error('Presenter not presented');
}
return this.result;
getResponseModel(): DriverRegistrationStatusDTO {
if (!this.responseModel) throw new Error('Presenter not presented');
return this.responseModel;
}
}

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { Result } from '@core/shared/application/Result';
import { DriverStatsPresenter } from './DriverStatsPresenter';
import type { TotalDriversResultDTO } from '../../../../../core/racing/application/presenters/ITotalDriversPresenter';
import type { GetTotalDriversResult } from '../../../../../core/racing/application/use-cases/GetTotalDriversUseCase';
describe('DriverStatsPresenter', () => {
let presenter: DriverStatsPresenter;
@@ -10,16 +11,18 @@ describe('DriverStatsPresenter', () => {
});
describe('present', () => {
it('should map core DTO to API view model correctly', () => {
const dto: TotalDriversResultDTO = {
it('should map core result to API response model correctly', () => {
const output: GetTotalDriversResult = {
totalDrivers: 42,
};
presenter.present(dto);
const result = Result.ok<GetTotalDriversResult, never>(output);
const result = presenter.viewModel;
presenter.present(result);
expect(result).toEqual({
const response = presenter.responseModel;
expect(response).toEqual({
totalDrivers: 42,
});
});
@@ -27,15 +30,17 @@ describe('DriverStatsPresenter', () => {
describe('reset', () => {
it('should reset the result', () => {
const dto: TotalDriversResultDTO = {
const output: GetTotalDriversResult = {
totalDrivers: 10,
};
presenter.present(dto);
expect(presenter.viewModel).toBeDefined();
const result = Result.ok<GetTotalDriversResult, never>(output);
presenter.present(result);
expect(presenter.responseModel).toBeDefined();
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
expect(() => presenter.responseModel).toThrow('Presenter not presented');
});
});
});

View File

@@ -1,21 +1,22 @@
import { DriverStatsDTO } from '../dtos/DriverStatsDTO';
import type { TotalDriversOutputPort } from '../../../../../core/racing/application/ports/output/TotalDriversOutputPort';
import type {
GetTotalDriversResult,
} from '@core/racing/application/use-cases/GetTotalDriversUseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
export class DriverStatsPresenter {
private result: DriverStatsDTO | null = null;
export class DriverStatsPresenter
implements UseCaseOutputPort<GetTotalDriversResult>
{
private responseModel: DriverStatsDTO | null = null;
reset() {
this.result = null;
}
present(output: TotalDriversOutputPort) {
this.result = {
totalDrivers: output.totalDrivers,
present(result: GetTotalDriversResult): void {
this.responseModel = {
totalDrivers: result.totalDrivers,
};
}
get viewModel(): DriverStatsDTO {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
getResponseModel(): DriverStatsDTO {
if (!this.responseModel) throw new Error('Presenter not presented');
return this.responseModel;
}
}

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { Result } from '@core/shared/application/Result';
import { DriversLeaderboardPresenter } from './DriversLeaderboardPresenter';
import type { DriversLeaderboardResultDTO } from '../../../../../core/racing/application/presenters/IDriversLeaderboardPresenter';
import type { GetDriversLeaderboardResult } from '../../../../../core/racing/application/use-cases/GetDriversLeaderboardUseCase';
describe('DriversLeaderboardPresenter', () => {
let presenter: DriversLeaderboardPresenter;
@@ -10,41 +11,50 @@ describe('DriversLeaderboardPresenter', () => {
});
describe('present', () => {
it('should map core DTO to API view model correctly', () => {
const dto: DriversLeaderboardResultDTO = {
drivers: [
it('should map core result to API response model correctly', () => {
const coreResult: GetDriversLeaderboardResult = {
items: [
{
id: 'driver-1',
name: 'Driver One',
country: 'US',
iracingId: '12345',
joinedAt: new Date('2023-01-01'),
driver: {
id: 'driver-1',
name: 'Driver One' as any,
country: 'US' as any,
} as any,
rating: 2500,
skillLevel: 'advanced' as any,
racesCompleted: 50,
wins: 10,
podiums: 20,
isActive: true,
rank: 1,
avatarUrl: 'https://example.com/avatar1.png',
},
{
id: 'driver-2',
name: 'Driver Two',
country: 'DE',
iracingId: '67890',
joinedAt: new Date('2023-01-02'),
driver: {
id: 'driver-2',
name: 'Driver Two' as any,
country: 'DE' as any,
} as any,
rating: 2400,
skillLevel: 'intermediate' as any,
racesCompleted: 40,
wins: 5,
podiums: 15,
isActive: true,
rank: 2,
avatarUrl: 'https://example.com/avatar2.png',
},
],
rankings: [
{ driverId: 'driver-1', rating: 2500, overallRank: 1 },
{ driverId: 'driver-2', rating: 2400, overallRank: 2 },
],
stats: {
'driver-1': { racesCompleted: 50, wins: 10, podiums: 20 },
'driver-2': { racesCompleted: 40, wins: 5, podiums: 15 },
},
avatarUrls: {
'driver-1': 'https://example.com/avatar1.png',
'driver-2': 'https://example.com/avatar2.png',
},
totalRaces: 90,
totalWins: 15,
activeCount: 2,
};
presenter.present(dto);
const result = Result.ok<GetDriversLeaderboardResult, never>(coreResult);
const result = presenter.viewModel;
presenter.present(result);
const api = presenter.responseModel;
expect(result.drivers).toHaveLength(2);
expect(result.drivers[0]).toEqual({

View File

@@ -1,36 +1,44 @@
import type { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { DriversLeaderboardDTO } from '../dtos/DriversLeaderboardDTO';
import type { DriversLeaderboardOutputPort } from '../../../../../core/racing/application/ports/output/DriversLeaderboardOutputPort';
import type {
GetDriversLeaderboardResult,
GetDriversLeaderboardErrorCode,
} from '@core/racing/application/use-cases/GetDriversLeaderboardUseCase';
export type DriversLeaderboardApplicationError = ApplicationErrorCode<
GetDriversLeaderboardErrorCode,
{ message: string }
>;
export class DriversLeaderboardPresenter {
private result: DriversLeaderboardDTO | null = null;
present(
result: Result<GetDriversLeaderboardResult, DriversLeaderboardApplicationError>,
): DriversLeaderboardDTO {
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Failed to load drivers leaderboard');
}
reset(): void {
this.result = null;
}
const output = result.unwrap();
present(output: DriversLeaderboardOutputPort): void {
this.result = {
drivers: output.drivers.map(driver => ({
id: driver.id,
name: driver.name,
rating: driver.rating,
skillLevel: driver.skillLevel,
nationality: driver.nationality,
racesCompleted: driver.racesCompleted,
wins: driver.wins,
podiums: driver.podiums,
isActive: driver.isActive,
rank: driver.rank,
avatarUrl: driver.avatarUrl,
return {
drivers: output.items.map(item => ({
id: item.driver.id,
name: item.driver.name.toString(),
rating: item.rating,
skillLevel: item.skillLevel,
nationality: item.driver.country.toString(),
racesCompleted: item.racesCompleted,
wins: item.wins,
podiums: item.podiums,
isActive: item.isActive,
rank: item.rank,
avatarUrl: item.avatarUrl,
})),
totalRaces: output.totalRaces,
totalWins: output.totalWins,
activeCount: output.activeCount,
totalRaces: output.items.reduce((sum, d) => sum + d.racesCompleted, 0),
totalWins: output.items.reduce((sum, d) => sum + d.wins, 0),
activeCount: output.items.filter(d => d.isActive).length,
};
}
get viewModel(): DriversLeaderboardDTO {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}