presenter refactoring

This commit is contained in:
2025-12-20 17:06:11 +01:00
parent 92be9d2e1b
commit e9d6f90bb2
109 changed files with 4159 additions and 1283 deletions

View File

@@ -18,6 +18,7 @@ describe('HelloService', () => {
});
it('should return "Hello World!"', () => {
expect(service.getHello()).toBe('Hello World!');
const presenter = service.getHello();
expect(presenter.viewModel).toEqual({ message: 'Hello World!' });
});
});

View File

@@ -1,9 +1,13 @@
import { Injectable } from '@nestjs/common';
import { HelloPresenter } from './presenters/HelloPresenter';
@Injectable()
export class HelloService {
getHello(): string {
return 'Hello World!';
getHello(): HelloPresenter {
const presenter = new HelloPresenter();
presenter.present('Hello World!');
return presenter;
}
}
}

View File

@@ -0,0 +1,22 @@
export interface HelloViewModel {
message: string;
}
export class HelloPresenter {
private result: HelloViewModel | null = null;
reset(): void {
this.result = null;
}
present(message: string): void {
this.result = { message };
}
get viewModel(): HelloViewModel {
if (!this.result) {
throw new Error('HelloPresenter not presented');
}
return this.result;
}
}

View File

@@ -42,8 +42,8 @@ describe('AnalyticsController', () => {
userAgent: 'Mozilla/5.0',
country: 'US',
};
const output = { pageViewId: 'pv-123' };
service.recordPageView.mockResolvedValue(output);
const presenterMock = { viewModel: { pageViewId: 'pv-123' } };
service.recordPageView.mockResolvedValue(presenterMock as any);
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
@@ -54,7 +54,7 @@ describe('AnalyticsController', () => {
expect(service.recordPageView).toHaveBeenCalledWith(input);
expect(mockRes.status).toHaveBeenCalledWith(201);
expect(mockRes.json).toHaveBeenCalledWith(output);
expect(mockRes.json).toHaveBeenCalledWith(presenterMock.viewModel);
});
});
@@ -69,8 +69,8 @@ describe('AnalyticsController', () => {
actorId: 'actor-789',
metadata: { key: 'value' },
};
const output = { eventId: 'event-123', engagementWeight: 10 };
service.recordEngagement.mockResolvedValue(output);
const presenterMock = { eventId: 'event-123', engagementWeight: 10 };
service.recordEngagement.mockResolvedValue(presenterMock as any);
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
@@ -81,41 +81,45 @@ describe('AnalyticsController', () => {
expect(service.recordEngagement).toHaveBeenCalledWith(input);
expect(mockRes.status).toHaveBeenCalledWith(201);
expect(mockRes.json).toHaveBeenCalledWith(output);
expect(mockRes.json).toHaveBeenCalledWith((presenterMock as any).viewModel);
});
});
describe('getDashboardData', () => {
it('should return dashboard data', async () => {
const output = {
totalUsers: 100,
activeUsers: 50,
totalRaces: 20,
totalLeagues: 5,
const presenterMock = {
viewModel: {
totalUsers: 100,
activeUsers: 50,
totalRaces: 20,
totalLeagues: 5,
},
};
service.getDashboardData.mockResolvedValue(output);
service.getDashboardData.mockResolvedValue(presenterMock as any);
const result = await controller.getDashboardData();
expect(service.getDashboardData).toHaveBeenCalled();
expect(result).toEqual(output);
expect(result).toEqual(presenterMock.viewModel);
});
});
describe('getAnalyticsMetrics', () => {
it('should return analytics metrics', async () => {
const output = {
pageViews: 1000,
uniqueVisitors: 500,
averageSessionDuration: 300,
bounceRate: 0.4,
const presenterMock = {
viewModel: {
pageViews: 1000,
uniqueVisitors: 500,
averageSessionDuration: 300,
bounceRate: 0.4,
},
};
service.getAnalyticsMetrics.mockResolvedValue(output);
service.getAnalyticsMetrics.mockResolvedValue(presenterMock as any);
const result = await controller.getAnalyticsMetrics();
expect(service.getAnalyticsMetrics).toHaveBeenCalled();
expect(result).toEqual(output);
expect(result).toEqual(presenterMock.viewModel);
});
});
});

View File

@@ -10,11 +10,7 @@ import { GetAnalyticsMetricsOutputDTO } from './dtos/GetAnalyticsMetricsOutputDT
import { AnalyticsService } from './AnalyticsService';
type RecordPageViewInput = RecordPageViewInputDTO;
type RecordPageViewOutput = RecordPageViewOutputDTO;
type RecordEngagementInput = RecordEngagementInputDTO;
type RecordEngagementOutput = RecordEngagementOutputDTO;
type GetDashboardDataOutput = GetDashboardDataOutputDTO;
type GetAnalyticsMetricsOutput = GetAnalyticsMetricsOutputDTO;
@ApiTags('analytics')
@Controller('analytics')
@@ -31,8 +27,8 @@ export class AnalyticsController {
@Body() input: RecordPageViewInput,
@Res() res: Response,
): Promise<void> {
const output: RecordPageViewOutput = await this.analyticsService.recordPageView(input);
res.status(HttpStatus.CREATED).json(output);
const presenter = await this.analyticsService.recordPageView(input);
res.status(HttpStatus.CREATED).json(presenter.viewModel);
}
@Post('engagement')
@@ -43,21 +39,23 @@ export class AnalyticsController {
@Body() input: RecordEngagementInput,
@Res() res: Response,
): Promise<void> {
const output: RecordEngagementOutput = await this.analyticsService.recordEngagement(input);
res.status(HttpStatus.CREATED).json(output);
const presenter = await this.analyticsService.recordEngagement(input);
res.status(HttpStatus.CREATED).json(presenter.viewModel);
}
@Get('dashboard')
@ApiOperation({ summary: 'Get analytics dashboard data' })
@ApiResponse({ status: 200, description: 'Dashboard data', type: GetDashboardDataOutputDTO })
async getDashboardData(): Promise<GetDashboardDataOutput> {
return await this.analyticsService.getDashboardData();
async getDashboardData(): Promise<GetDashboardDataOutputDTO> {
const presenter = await this.analyticsService.getDashboardData();
return presenter.viewModel;
}
@Get('metrics')
@ApiOperation({ summary: 'Get analytics metrics' })
@ApiResponse({ status: 200, description: 'Analytics metrics', type: GetAnalyticsMetricsOutputDTO })
async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsOutput> {
return await this.analyticsService.getAnalyticsMetrics();
async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsOutputDTO> {
const presenter = await this.analyticsService.getAnalyticsMetrics();
return presenter.viewModel;
}
}

View File

@@ -9,13 +9,13 @@ import { RecordPageViewUseCase } from '@core/analytics/application/use-cases/Rec
import { RecordEngagementUseCase } from '@core/analytics/application/use-cases/RecordEngagementUseCase';
import { GetDashboardDataUseCase } from '@core/analytics/application/use-cases/GetDashboardDataUseCase';
import { GetAnalyticsMetricsUseCase } from '@core/analytics/application/use-cases/GetAnalyticsMetricsUseCase';
import { RecordPageViewPresenter } from './presenters/RecordPageViewPresenter';
import { RecordEngagementPresenter } from './presenters/RecordEngagementPresenter';
import { GetDashboardDataPresenter } from './presenters/GetDashboardDataPresenter';
import { GetAnalyticsMetricsPresenter } from './presenters/GetAnalyticsMetricsPresenter';
type RecordPageViewInput = RecordPageViewInputDTO;
type RecordPageViewOutput = RecordPageViewOutputDTO;
type RecordEngagementInput = RecordEngagementInputDTO;
type RecordEngagementOutput = RecordEngagementOutputDTO;
type GetDashboardDataOutput = GetDashboardDataOutputDTO;
type GetAnalyticsMetricsOutput = GetAnalyticsMetricsOutputDTO;
const RECORD_PAGE_VIEW_USE_CASE_TOKEN = 'RecordPageViewUseCase_TOKEN';
const RECORD_ENGAGEMENT_USE_CASE_TOKEN = 'RecordEngagementUseCase_TOKEN';
@@ -31,19 +31,27 @@ export class AnalyticsService {
@Inject(GET_ANALYTICS_METRICS_USE_CASE_TOKEN) private readonly getAnalyticsMetricsUseCase: GetAnalyticsMetricsUseCase,
) {}
async recordPageView(input: RecordPageViewInput): Promise<RecordPageViewOutput> {
return await this.recordPageViewUseCase.execute(input);
async recordPageView(input: RecordPageViewInput): Promise<RecordPageViewPresenter> {
const presenter = new RecordPageViewPresenter();
await this.recordPageViewUseCase.execute(input, presenter);
return presenter;
}
async recordEngagement(input: RecordEngagementInput): Promise<RecordEngagementOutput> {
return await this.recordEngagementUseCase.execute(input);
async recordEngagement(input: RecordEngagementInput): Promise<RecordEngagementPresenter> {
const presenter = new RecordEngagementPresenter();
await this.recordEngagementUseCase.execute(input, presenter);
return presenter;
}
async getDashboardData(): Promise<GetDashboardDataOutput> {
return await this.getDashboardDataUseCase.execute();
async getDashboardData(): Promise<GetDashboardDataPresenter> {
const presenter = new GetDashboardDataPresenter();
await this.getDashboardDataUseCase.execute(undefined, presenter);
return presenter;
}
async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsOutput> {
return await this.getAnalyticsMetricsUseCase.execute();
async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsPresenter> {
const presenter = new GetAnalyticsMetricsPresenter();
await this.getAnalyticsMetricsUseCase.execute(undefined, presenter);
return presenter;
}
}

View File

@@ -0,0 +1,45 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { GetAnalyticsMetricsPresenter } from './GetAnalyticsMetricsPresenter';
import type { GetAnalyticsMetricsOutput } from '@core/analytics/application/use-cases/GetAnalyticsMetricsUseCase';
describe('GetAnalyticsMetricsPresenter', () => {
let presenter: GetAnalyticsMetricsPresenter;
beforeEach(() => {
presenter = new GetAnalyticsMetricsPresenter();
});
it('maps use case output to DTO correctly', () => {
const output: GetAnalyticsMetricsOutput = {
pageViews: 1000,
uniqueVisitors: 500,
averageSessionDuration: 300,
bounceRate: 0.4,
};
presenter.present(output);
expect(presenter.viewModel).toEqual({
pageViews: 1000,
uniqueVisitors: 500,
averageSessionDuration: 300,
bounceRate: 0.4,
});
});
it('reset clears state and causes viewModel to throw', () => {
const output: GetAnalyticsMetricsOutput = {
pageViews: 1000,
uniqueVisitors: 500,
averageSessionDuration: 300,
bounceRate: 0.4,
};
presenter.present(output);
expect(presenter.viewModel).toBeDefined();
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
});

View File

@@ -0,0 +1,24 @@
import type { GetAnalyticsMetricsOutput } from '@core/analytics/application/use-cases/GetAnalyticsMetricsUseCase';
import type { GetAnalyticsMetricsOutputDTO } from '../dtos/GetAnalyticsMetricsOutputDTO';
export class GetAnalyticsMetricsPresenter {
private result: GetAnalyticsMetricsOutputDTO | null = null;
reset() {
this.result = null;
}
present(output: GetAnalyticsMetricsOutput): void {
this.result = {
pageViews: output.pageViews,
uniqueVisitors: output.uniqueVisitors,
averageSessionDuration: output.averageSessionDuration,
bounceRate: output.bounceRate,
};
}
get viewModel(): GetAnalyticsMetricsOutputDTO {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,45 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { GetDashboardDataPresenter } from './GetDashboardDataPresenter';
import type { GetDashboardDataOutput } from '@core/analytics/application/use-cases/GetDashboardDataUseCase';
describe('GetDashboardDataPresenter', () => {
let presenter: GetDashboardDataPresenter;
beforeEach(() => {
presenter = new GetDashboardDataPresenter();
});
it('maps use case output to DTO correctly', () => {
const output: GetDashboardDataOutput = {
totalUsers: 100,
activeUsers: 50,
totalRaces: 20,
totalLeagues: 5,
};
presenter.present(output);
expect(presenter.viewModel).toEqual({
totalUsers: 100,
activeUsers: 50,
totalRaces: 20,
totalLeagues: 5,
});
});
it('reset clears state and causes viewModel to throw', () => {
const output: GetDashboardDataOutput = {
totalUsers: 100,
activeUsers: 50,
totalRaces: 20,
totalLeagues: 5,
};
presenter.present(output);
expect(presenter.viewModel).toBeDefined();
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
});

View File

@@ -0,0 +1,24 @@
import type { GetDashboardDataOutput } from '@core/analytics/application/use-cases/GetDashboardDataUseCase';
import type { GetDashboardDataOutputDTO } from '../dtos/GetDashboardDataOutputDTO';
export class GetDashboardDataPresenter {
private result: GetDashboardDataOutputDTO | null = null;
reset() {
this.result = null;
}
present(output: GetDashboardDataOutput): void {
this.result = {
totalUsers: output.totalUsers,
activeUsers: output.activeUsers,
totalRaces: output.totalRaces,
totalLeagues: output.totalLeagues,
};
}
get viewModel(): GetDashboardDataOutputDTO {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,39 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { RecordEngagementPresenter } from './RecordEngagementPresenter';
import type { RecordEngagementOutput } from '@core/analytics/application/use-cases/RecordEngagementUseCase';
describe('RecordEngagementPresenter', () => {
let presenter: RecordEngagementPresenter;
beforeEach(() => {
presenter = new RecordEngagementPresenter();
});
it('maps use case output to DTO correctly', () => {
const output: RecordEngagementOutput = {
eventId: 'event-123',
engagementWeight: 10,
} as RecordEngagementOutput;
presenter.present(output);
expect(presenter.viewModel).toEqual({
eventId: 'event-123',
engagementWeight: 10,
});
});
it('reset clears state and causes viewModel to throw', () => {
const output: RecordEngagementOutput = {
eventId: 'event-123',
engagementWeight: 10,
} as RecordEngagementOutput;
presenter.present(output);
expect(presenter.viewModel).toBeDefined();
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
});

View File

@@ -0,0 +1,22 @@
import type { RecordEngagementOutput } from '@core/analytics/application/use-cases/RecordEngagementUseCase';
import type { RecordEngagementOutputDTO } from '../dtos/RecordEngagementOutputDTO';
export class RecordEngagementPresenter {
private result: RecordEngagementOutputDTO | null = null;
reset() {
this.result = null;
}
present(output: RecordEngagementOutput): void {
this.result = {
eventId: output.eventId,
engagementWeight: output.engagementWeight,
};
}
get viewModel(): RecordEngagementOutputDTO {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,36 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { RecordPageViewPresenter } from './RecordPageViewPresenter';
import type { RecordPageViewOutput } from '@core/analytics/application/use-cases/RecordPageViewUseCase';
describe('RecordPageViewPresenter', () => {
let presenter: RecordPageViewPresenter;
beforeEach(() => {
presenter = new RecordPageViewPresenter();
});
it('maps use case output to DTO correctly', () => {
const output: RecordPageViewOutput = {
pageViewId: 'pv-123',
} as RecordPageViewOutput;
presenter.present(output);
expect(presenter.viewModel).toEqual({
pageViewId: 'pv-123',
});
});
it('reset clears state and causes viewModel to throw', () => {
const output: RecordPageViewOutput = {
pageViewId: 'pv-123',
} as RecordPageViewOutput;
presenter.present(output);
expect(presenter.viewModel).toBeDefined();
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
});

View File

@@ -0,0 +1,21 @@
import type { RecordPageViewOutput } from '@core/analytics/application/use-cases/RecordPageViewUseCase';
import type { RecordPageViewOutputDTO } from '../dtos/RecordPageViewOutputDTO';
export class RecordPageViewPresenter {
private result: RecordPageViewOutputDTO | null = null;
reset() {
this.result = null;
}
present(output: RecordPageViewOutput): void {
this.result = {
pageViewId: output.pageViewId,
};
}
get viewModel(): RecordPageViewOutputDTO {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -8,22 +8,26 @@ export class AuthController {
@Post('signup')
async signup(@Body() params: SignupParams): Promise<AuthSessionDTO> {
return this.authService.signupWithEmail(params);
const presenter = await this.authService.signupWithEmail(params);
return presenter.viewModel;
}
@Post('login')
async login(@Body() params: LoginParams): Promise<AuthSessionDTO> {
return this.authService.loginWithEmail(params);
const presenter = await this.authService.loginWithEmail(params);
return presenter.viewModel;
}
@Get('session')
async getSession(): Promise<AuthSessionDTO | null> {
return this.authService.getCurrentSession();
const presenter = await this.authService.getCurrentSession();
return presenter ? presenter.viewModel : null;
}
@Post('logout')
async logout(): Promise<void> {
return this.authService.logout();
async logout(): Promise<{ success: boolean }> {
const presenter = await this.authService.logout();
return presenter.viewModel;
}
}

View File

@@ -15,6 +15,8 @@ import type { IPasswordHashingService } from '@core/identity/domain/services/Pas
import type { Logger } from "@core/shared/application";
import { AUTH_REPOSITORY_TOKEN, IDENTITY_SESSION_PORT_TOKEN, LOGGER_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, USER_REPOSITORY_TOKEN } from './AuthProviders';
import { AuthSessionDTO, LoginParams, SignupParams, AuthenticatedUserDTO } from './dtos/AuthDto';
import { AuthSessionPresenter } from './presenters/AuthSessionPresenter';
import { CommandResultPresenter } from './presenters/CommandResultPresenter';
@Injectable()
export class AuthService {
@@ -50,7 +52,7 @@ export class AuthService {
};
}
async getCurrentSession(): Promise<AuthSessionDTO | null> {
async getCurrentSession(): Promise<AuthSessionPresenter | null> {
this.logger.debug('[AuthService] Attempting to get current session.');
const coreSession = await this.identitySessionPort.getCurrentSession();
if (!coreSession) {
@@ -59,36 +61,34 @@ export class AuthService {
const user = await this.userRepository.findById(coreSession.user.id); // Use userRepository to fetch full user
if (!user) {
// If session exists but user doesn't in DB, perhaps clear session?
this.logger.warn(`[AuthService] Session found for user ID ${coreSession.user.id}, but user not found in repository.`);
await this.identitySessionPort.clearSession(); // Clear potentially stale session
return null;
// If session exists but user doesn't in DB, perhaps clear session?
this.logger.warn(`[AuthService] Session found for user ID ${coreSession.user.id}, but user not found in repository.`);
await this.identitySessionPort.clearSession(); // Clear potentially stale session
return null;
}
const authenticatedUserDTO = this.mapUserToAuthenticatedUserDTO(User.fromStored(user));
return {
token: coreSession.token,
user: authenticatedUserDTO,
};
const presenter = new AuthSessionPresenter();
presenter.present({ token: coreSession.token, user: authenticatedUserDTO });
return presenter;
}
async signupWithEmail(params: SignupParams): Promise<AuthSessionDTO> {
async signupWithEmail(params: SignupParams): Promise<AuthSessionPresenter> {
this.logger.debug(`[AuthService] Attempting signup for email: ${params.email}`);
const user = await this.signupUseCase.execute(params.email, params.password, params.displayName);
// Create session after successful signup
const authenticatedUserDTO = this.mapUserToAuthenticatedUserDTO(user);
const coreDto = this.mapToCoreAuthenticatedUserDTO(authenticatedUserDTO);
const session = await this.identitySessionPort.createSession(coreDto);
return {
token: session.token,
user: authenticatedUserDTO,
};
const presenter = new AuthSessionPresenter();
presenter.present({ token: session.token, user: authenticatedUserDTO });
return presenter;
}
async loginWithEmail(params: LoginParams): Promise<AuthSessionDTO> {
async loginWithEmail(params: LoginParams): Promise<AuthSessionPresenter> {
this.logger.debug(`[AuthService] Attempting login for email: ${params.email}`);
try {
const user = await this.loginUseCase.execute(params.email, params.password);
@@ -97,10 +97,9 @@ export class AuthService {
const coreDto = this.mapToCoreAuthenticatedUserDTO(authenticatedUserDTO);
const session = await this.identitySessionPort.createSession(coreDto);
return {
token: session.token,
user: authenticatedUserDTO,
};
const presenter = new AuthSessionPresenter();
presenter.present({ token: session.token, user: authenticatedUserDTO });
return presenter;
} catch (error) {
this.logger.error(`[AuthService] Login failed for email ${params.email}:`, error instanceof Error ? error : new Error(String(error)));
throw new InternalServerErrorException('Login failed due to invalid credentials or server error.');
@@ -108,8 +107,11 @@ export class AuthService {
}
async logout(): Promise<void> {
async logout(): Promise<CommandResultPresenter> {
this.logger.debug('[AuthService] Attempting logout.');
const presenter = new CommandResultPresenter();
await this.logoutUseCase.execute();
presenter.present({ success: true });
return presenter;
}
}

View File

@@ -0,0 +1,61 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { AuthSessionPresenter } from './AuthSessionPresenter';
import { AuthenticatedUserDTO } from '../dtos/AuthDto';
describe('AuthSessionPresenter', () => {
let presenter: AuthSessionPresenter;
beforeEach(() => {
presenter = new AuthSessionPresenter();
});
it('maps token and user DTO correctly', () => {
const user: AuthenticatedUserDTO = {
userId: 'user-1',
email: 'user@example.com',
displayName: 'Test User',
};
presenter.present({ token: 'token-123', user });
expect(presenter.viewModel).toEqual({
token: 'token-123',
user: {
userId: 'user-1',
email: 'user@example.com',
displayName: 'Test User',
},
});
});
it('reset clears state and causes viewModel to throw', () => {
const user: AuthenticatedUserDTO = {
userId: 'user-1',
email: 'user@example.com',
displayName: 'Test User',
};
presenter.present({ token: 'token-123', user });
expect(presenter.viewModel).toBeDefined();
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
it('getViewModel returns null when not presented', () => {
expect(presenter.getViewModel()).toBeNull();
});
it('getViewModel returns the same DTO after present', () => {
const user: AuthenticatedUserDTO = {
userId: 'user-1',
email: 'user@example.com',
displayName: 'Test User',
};
presenter.present({ token: 'token-123', user });
expect(presenter.getViewModel()).toEqual(presenter.viewModel);
});
});

View File

@@ -0,0 +1,31 @@
import { AuthSessionDTO, AuthenticatedUserDTO } from '../dtos/AuthDto';
export interface AuthSessionViewModel extends AuthSessionDTO {}
export class AuthSessionPresenter {
private result: AuthSessionViewModel | null = null;
reset() {
this.result = null;
}
present(input: { token: string; user: AuthenticatedUserDTO }): void {
this.result = {
token: input.token,
user: {
userId: input.user.userId,
email: input.user.email,
displayName: input.user.displayName,
},
};
}
get viewModel(): AuthSessionViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
getViewModel(): AuthSessionViewModel | null {
return this.result;
}
}

View File

@@ -13,6 +13,7 @@ export class DashboardController {
@ApiQuery({ name: 'driverId', description: 'Driver ID' })
@ApiResponse({ status: 200, description: 'Dashboard overview', type: DashboardOverviewDTO })
async getDashboardOverview(@Query('driverId') driverId: string): Promise<DashboardOverviewDTO> {
return this.dashboardService.getDashboardOverview(driverId);
const presenter = await this.dashboardService.getDashboardOverview(driverId);
return presenter.viewModel;
}
}

View File

@@ -1,8 +1,8 @@
import { Injectable, Inject } from '@nestjs/common';
import { plainToClass } from 'class-transformer';
import { DashboardOverviewUseCase } from '@core/racing/application/use-cases/DashboardOverviewUseCase';
import type { DashboardOverviewOutputPort } from '@core/racing/application/ports/output/DashboardOverviewOutputPort';
import { DashboardOverviewDTO } from './dtos/DashboardOverviewDTO';
import { DashboardOverviewPresenter } from './presenters/DashboardOverviewPresenter';
// Core imports
import type { Logger } from '@core/shared/application/Logger';
@@ -64,7 +64,7 @@ export class DashboardService {
);
}
async getDashboardOverview(driverId: string): Promise<DashboardOverviewDTO> {
async getDashboardOverview(driverId: string): Promise<DashboardOverviewPresenter> {
this.logger.debug('[DashboardService] Getting dashboard overview:', { driverId });
const result = await this.dashboardOverviewUseCase.execute({ driverId });
@@ -73,6 +73,8 @@ export class DashboardService {
throw new Error(result.error?.message || 'Failed to get dashboard overview');
}
return plainToClass(DashboardOverviewDTO, result.value);
const presenter = new DashboardOverviewPresenter();
presenter.present(result.value as DashboardOverviewOutputPort);
return presenter;
}
}

View File

@@ -0,0 +1,166 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { DashboardOverviewPresenter } from './DashboardOverviewPresenter';
import type { DashboardOverviewOutputPort } from '@core/racing/application/ports/output/DashboardOverviewOutputPort';
const createOutput = (): DashboardOverviewOutputPort => ({
currentDriver: {
id: 'driver-1',
name: 'Test Driver',
country: 'DE',
avatarUrl: 'https://example.com/avatar.jpg',
rating: 2500,
globalRank: 42,
totalRaces: 10,
wins: 3,
podiums: 5,
consistency: 90,
},
myUpcomingRaces: [
{
id: 'race-1',
leagueId: 'league-1',
leagueName: 'League 1',
track: 'Spa',
car: 'GT3',
scheduledAt: '2025-01-01T10:00:00Z',
status: 'scheduled',
isMyLeague: true,
},
],
otherUpcomingRaces: [
{
id: 'race-2',
leagueId: 'league-2',
leagueName: 'League 2',
track: 'Monza',
car: 'GT3',
scheduledAt: '2025-01-02T10:00:00Z',
status: 'scheduled',
isMyLeague: false,
},
],
upcomingRaces: [
{
id: 'race-1',
leagueId: 'league-1',
leagueName: 'League 1',
track: 'Spa',
car: 'GT3',
scheduledAt: '2025-01-01T10:00:00Z',
status: 'scheduled',
isMyLeague: true,
},
{
id: 'race-2',
leagueId: 'league-2',
leagueName: 'League 2',
track: 'Monza',
car: 'GT3',
scheduledAt: '2025-01-02T10:00:00Z',
status: 'scheduled',
isMyLeague: false,
},
],
activeLeaguesCount: 2,
nextRace: {
id: 'race-1',
leagueId: 'league-1',
leagueName: 'League 1',
track: 'Spa',
car: 'GT3',
scheduledAt: '2025-01-01T10:00:00Z',
status: 'scheduled',
isMyLeague: true,
},
recentResults: [
{
raceId: 'race-3',
raceName: 'Nürburgring',
leagueId: 'league-3',
leagueName: 'League 3',
finishedAt: '2024-12-01T10:00:00Z',
position: 1,
incidents: 0,
},
],
leagueStandingsSummaries: [
{
leagueId: 'league-1',
leagueName: 'League 1',
position: 1,
totalDrivers: 20,
points: 150,
},
],
feedSummary: {
notificationCount: 3,
items: [
{
id: 'feed-1',
type: 'race_result' as any,
headline: 'You won a race',
body: 'Congrats!',
timestamp: '2024-12-02T10:00:00Z',
ctaLabel: 'View',
ctaHref: '/races/race-3',
},
],
},
friends: [
{
id: 'friend-1',
name: 'Friend One',
country: 'US',
avatarUrl: 'https://example.com/friend.jpg',
},
],
});
describe('DashboardOverviewPresenter', () => {
let presenter: DashboardOverviewPresenter;
beforeEach(() => {
presenter = new DashboardOverviewPresenter();
});
it('maps DashboardOverviewOutputPort to DashboardOverviewDTO correctly', () => {
const output = createOutput();
presenter.present(output);
const viewModel = presenter.viewModel;
expect(viewModel.activeLeaguesCount).toBe(2);
expect(viewModel.currentDriver?.id).toBe('driver-1');
expect(viewModel.myUpcomingRaces[0].id).toBe('race-1');
expect(viewModel.otherUpcomingRaces[0].id).toBe('race-2');
expect(viewModel.upcomingRaces).toHaveLength(2);
expect(viewModel.nextRace?.id).toBe('race-1');
expect(viewModel.recentResults[0].raceId).toBe('race-3');
expect(viewModel.leagueStandingsSummaries[0].leagueId).toBe('league-1');
expect(viewModel.feedSummary.notificationCount).toBe(3);
expect(viewModel.feedSummary.items[0].id).toBe('feed-1');
expect(viewModel.friends[0].id).toBe('friend-1');
});
it('reset clears state and causes viewModel to throw', () => {
const output = createOutput();
presenter.present(output);
expect(presenter.viewModel).toBeDefined();
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
it('getViewModel returns null when not presented', () => {
expect(presenter.getViewModel()).toBeNull();
});
it('getViewModel returns same DTO after present', () => {
const output = createOutput();
presenter.present(output);
expect(presenter.getViewModel()).toEqual(presenter.viewModel);
});
});

View File

@@ -0,0 +1,116 @@
import type { DashboardOverviewOutputPort } from '@core/racing/application/ports/output/DashboardOverviewOutputPort';
import {
DashboardOverviewDTO,
DashboardDriverSummaryDTO,
DashboardRaceSummaryDTO,
DashboardRecentResultDTO,
DashboardLeagueStandingSummaryDTO,
DashboardFeedSummaryDTO,
DashboardFeedItemSummaryDTO,
DashboardFriendSummaryDTO,
} from '../dtos/DashboardOverviewDTO';
export class DashboardOverviewPresenter {
private result: DashboardOverviewDTO | null = null;
reset() {
this.result = null;
}
present(output: DashboardOverviewOutputPort): void {
const currentDriver: DashboardDriverSummaryDTO | null = output.currentDriver
? {
id: output.currentDriver.id,
name: output.currentDriver.name,
country: output.currentDriver.country,
avatarUrl: output.currentDriver.avatarUrl,
rating: output.currentDriver.rating,
globalRank: output.currentDriver.globalRank,
totalRaces: output.currentDriver.totalRaces,
wins: output.currentDriver.wins,
podiums: output.currentDriver.podiums,
consistency: output.currentDriver.consistency,
}
: null;
const mapRace = (race: typeof output.myUpcomingRaces[number]): DashboardRaceSummaryDTO => ({
id: race.id,
leagueId: race.leagueId,
leagueName: race.leagueName,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt,
status: race.status,
isMyLeague: race.isMyLeague,
});
const myUpcomingRaces: DashboardRaceSummaryDTO[] = output.myUpcomingRaces.map(mapRace);
const otherUpcomingRaces: DashboardRaceSummaryDTO[] = output.otherUpcomingRaces.map(mapRace);
const upcomingRaces: DashboardRaceSummaryDTO[] = output.upcomingRaces.map(mapRace);
const nextRace: DashboardRaceSummaryDTO | null = output.nextRace ? mapRace(output.nextRace) : null;
const recentResults: DashboardRecentResultDTO[] = output.recentResults.map(result => ({
raceId: result.raceId,
raceName: result.raceName,
leagueId: result.leagueId,
leagueName: result.leagueName,
finishedAt: result.finishedAt,
position: result.position,
incidents: result.incidents,
}));
const leagueStandingsSummaries: DashboardLeagueStandingSummaryDTO[] =
output.leagueStandingsSummaries.map(standing => ({
leagueId: standing.leagueId,
leagueName: standing.leagueName,
position: standing.position,
totalDrivers: standing.totalDrivers,
points: standing.points,
}));
const feedItems: DashboardFeedItemSummaryDTO[] = output.feedSummary.items.map(item => ({
id: item.id,
type: item.type,
headline: item.headline,
body: item.body,
timestamp: item.timestamp,
ctaLabel: item.ctaLabel,
ctaHref: item.ctaHref,
}));
const feedSummary: DashboardFeedSummaryDTO = {
notificationCount: output.feedSummary.notificationCount,
items: feedItems,
};
const friends: DashboardFriendSummaryDTO[] = output.friends.map(friend => ({
id: friend.id,
name: friend.name,
country: friend.country,
avatarUrl: friend.avatarUrl,
}));
this.result = {
currentDriver,
myUpcomingRaces,
otherUpcomingRaces,
upcomingRaces,
activeLeaguesCount: output.activeLeaguesCount,
nextRace,
recentResults,
leagueStandingsSummaries,
feedSummary,
friends,
};
}
get viewModel(): DashboardOverviewDTO {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
getViewModel(): DashboardOverviewDTO | null {
return this.result;
}
}

View File

@@ -46,7 +46,7 @@ describe('DriverController', () => {
describe('getDriversLeaderboard', () => {
it('should return drivers leaderboard', async () => {
const leaderboard: DriversLeaderboardDTO = { items: [] };
service.getDriversLeaderboard.mockResolvedValue(leaderboard);
service.getDriversLeaderboard.mockResolvedValue({ viewModel: leaderboard } as never);
const result = await controller.getDriversLeaderboard();
@@ -58,7 +58,7 @@ describe('DriverController', () => {
describe('getTotalDrivers', () => {
it('should return total drivers stats', async () => {
const stats: DriverStatsDTO = { totalDrivers: 100 };
service.getTotalDrivers.mockResolvedValue(stats);
service.getTotalDrivers.mockResolvedValue({ viewModel: stats } as never);
const result = await controller.getTotalDrivers();
@@ -70,8 +70,8 @@ describe('DriverController', () => {
describe('getCurrentDriver', () => {
it('should return current driver if userId exists', async () => {
const userId = 'user-123';
const driver: GetDriverOutputDTO = { id: 'driver-123', name: 'Driver' };
service.getCurrentDriver.mockResolvedValue(driver);
const driver: GetDriverOutputDTO = { id: 'driver-123', name: 'Driver' } as GetDriverOutputDTO;
service.getCurrentDriver.mockResolvedValue({ viewModel: driver } as never);
const mockReq: Partial<AuthenticatedRequest> = { user: { userId } };
@@ -94,9 +94,9 @@ describe('DriverController', () => {
describe('completeOnboarding', () => {
it('should complete onboarding', async () => {
const userId = 'user-123';
const input: CompleteOnboardingInputDTO = { someField: 'value' };
const input: CompleteOnboardingInputDTO = { someField: 'value' } as CompleteOnboardingInputDTO;
const output: CompleteOnboardingOutputDTO = { success: true };
service.completeOnboarding.mockResolvedValue(output);
service.completeOnboarding.mockResolvedValue({ viewModel: output } as never);
const mockReq: Partial<AuthenticatedRequest> = { user: { userId } };
@@ -111,8 +111,8 @@ describe('DriverController', () => {
it('should return registration status', async () => {
const driverId = 'driver-123';
const raceId = 'race-456';
const status: DriverRegistrationStatusDTO = { registered: true };
service.getDriverRegistrationStatus.mockResolvedValue(status);
const status: DriverRegistrationStatusDTO = { registered: true } as DriverRegistrationStatusDTO;
service.getDriverRegistrationStatus.mockResolvedValue({ viewModel: status } as never);
const result = await controller.getDriverRegistrationStatus(driverId, raceId);
@@ -124,8 +124,8 @@ describe('DriverController', () => {
describe('getDriver', () => {
it('should return driver by id', async () => {
const driverId = 'driver-123';
const driver: GetDriverOutputDTO = { id: driverId, name: 'Driver' };
service.getDriver.mockResolvedValue(driver);
const driver: GetDriverOutputDTO = { id: driverId, name: 'Driver' } as GetDriverOutputDTO;
service.getDriver.mockResolvedValue({ viewModel: driver } as never);
const result = await controller.getDriver(driverId);

View File

@@ -25,14 +25,16 @@ export class DriverController {
@ApiOperation({ summary: 'Get drivers leaderboard' })
@ApiResponse({ status: 200, description: 'List of drivers for the leaderboard', type: DriversLeaderboardDTO })
async getDriversLeaderboard(): Promise<DriversLeaderboardDTO> {
return this.driverService.getDriversLeaderboard();
const presenter = await this.driverService.getDriversLeaderboard();
return presenter.viewModel;
}
@Get('total-drivers')
@ApiOperation({ summary: 'Get the total number of drivers' })
@ApiResponse({ status: 200, description: 'Total number of drivers', type: DriverStatsDTO })
async getTotalDrivers(): Promise<DriverStatsDTO> {
return this.driverService.getTotalDrivers();
const presenter = await this.driverService.getTotalDrivers();
return presenter.viewModel;
}
@Get('current')
@@ -40,12 +42,13 @@ export class DriverController {
@ApiResponse({ status: 200, description: 'Current driver data', type: GetDriverOutputDTO })
@ApiResponse({ status: 404, description: 'Driver not found' })
async getCurrentDriver(@Req() req: AuthenticatedRequest): Promise<GetDriverOutputDTO | null> {
// Assuming userId is available from the request (e.g., via auth middleware)
const userId = req.user?.userId;
if (!userId) {
return null;
}
return this.driverService.getCurrentDriver(userId);
const presenter = await this.driverService.getCurrentDriver(userId);
return presenter.viewModel;
}
@Post('complete-onboarding')
@@ -55,9 +58,9 @@ export class DriverController {
@Body() input: CompleteOnboardingInputDTO,
@Req() req: AuthenticatedRequest,
): Promise<CompleteOnboardingOutputDTO> {
// Assuming userId is available from the request (e.g., via auth middleware)
const userId = req.user!.userId; // Placeholder for actual user extraction
return this.driverService.completeOnboarding(userId, input);
const userId = req.user!.userId;
const presenter = await this.driverService.completeOnboarding(userId, input);
return presenter.viewModel;
}
@Get(':driverId/races/:raceId/registration-status')
@@ -67,7 +70,8 @@ export class DriverController {
@Param('driverId') driverId: string,
@Param('raceId') raceId: string,
): Promise<DriverRegistrationStatusDTO> {
return this.driverService.getDriverRegistrationStatus({ driverId, raceId });
const presenter = await this.driverService.getDriverRegistrationStatus({ driverId, raceId });
return presenter.viewModel;
}
@Get(':driverId')
@@ -75,7 +79,8 @@ export class DriverController {
@ApiResponse({ status: 200, description: 'Driver data', type: GetDriverOutputDTO })
@ApiResponse({ status: 404, description: 'Driver not found' })
async getDriver(@Param('driverId') driverId: string): Promise<GetDriverOutputDTO | null> {
return this.driverService.getDriver(driverId);
const presenter = await this.driverService.getDriver(driverId);
return presenter.viewModel;
}
@Get(':driverId/profile')
@@ -83,7 +88,8 @@ export class DriverController {
@ApiResponse({ status: 200, description: 'Driver profile data', type: GetDriverProfileOutputDTO })
@ApiResponse({ status: 404, description: 'Driver not found' })
async getDriverProfile(@Param('driverId') driverId: string): Promise<GetDriverProfileOutputDTO> {
return this.driverService.getDriverProfile(driverId);
const presenter = await this.driverService.getDriverProfile(driverId);
return presenter.viewModel;
}
@Put(':driverId/profile')
@@ -93,7 +99,8 @@ export class DriverController {
@Param('driverId') driverId: string,
@Body() body: { bio?: string; country?: string },
): Promise<GetDriverOutputDTO | null> {
return this.driverService.updateDriverProfile(driverId, body.bio, body.country);
const presenter = await this.driverService.updateDriverProfile(driverId, body.bio, body.country);
return presenter.viewModel;
}
// Add other Driver endpoints here based on other presenters

View File

@@ -1,12 +1,6 @@
import { Injectable, Inject } from '@nestjs/common';
import { DriversLeaderboardDTO } from './dtos/DriversLeaderboardDTO';
import { DriverStatsDTO } from './dtos/DriverStatsDTO';
import { CompleteOnboardingInputDTO } from './dtos/CompleteOnboardingInputDTO';
import { CompleteOnboardingOutputDTO } from './dtos/CompleteOnboardingOutputDTO';
import { GetDriverRegistrationStatusQueryDTO } from './dtos/GetDriverRegistrationStatusQueryDTO';
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';
@@ -14,42 +8,66 @@ import { GetTotalDriversUseCase } from '@core/racing/application/use-cases/GetTo
import { CompleteDriverOnboardingUseCase } from '@core/racing/application/use-cases/CompleteDriverOnboardingUseCase';
import { IsDriverRegisteredForRaceUseCase } from '@core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase';
import { GetProfileOverviewUseCase } from '@core/racing/application/use-cases/GetProfileOverviewUseCase';
import { UpdateDriverProfileUseCase } from '@core/racing/application/use-cases/UpdateDriverProfileUseCase';
// 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';
// Tokens
import { GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN, GET_TOTAL_DRIVERS_USE_CASE_TOKEN, COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN, IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN, UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN, GET_PROFILE_OVERVIEW_USE_CASE_TOKEN, LOGGER_TOKEN, DRIVER_REPOSITORY_TOKEN } from './DriverProviders';
import type { ProfileOverviewOutputPort } from '@core/racing/application/ports/output/ProfileOverviewOutputPort';
import {
GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN,
GET_TOTAL_DRIVERS_USE_CASE_TOKEN,
COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN,
IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN,
UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN,
GET_PROFILE_OVERVIEW_USE_CASE_TOKEN,
LOGGER_TOKEN,
DRIVER_REPOSITORY_TOKEN,
} from './DriverProviders';
import type { Logger } from '@core/shared/application';
import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
import { UpdateDriverProfileUseCase } from '@core/racing/application/use-cases/UpdateDriverProfileUseCase';
@Injectable()
export class DriverService {
constructor(
@Inject(GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN) private readonly getDriversLeaderboardUseCase: GetDriversLeaderboardUseCase,
@Inject(GET_TOTAL_DRIVERS_USE_CASE_TOKEN) private readonly getTotalDriversUseCase: GetTotalDriversUseCase,
@Inject(COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN) private readonly completeDriverOnboardingUseCase: CompleteDriverOnboardingUseCase,
@Inject(IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN) private readonly isDriverRegisteredForRaceUseCase: IsDriverRegisteredForRaceUseCase,
@Inject(UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN) private readonly updateDriverProfileUseCase: UpdateDriverProfileUseCase,
@Inject(GET_PROFILE_OVERVIEW_USE_CASE_TOKEN) private readonly getProfileOverviewUseCase: GetProfileOverviewUseCase,
@Inject(DRIVER_REPOSITORY_TOKEN) private readonly driverRepository: IDriverRepository,
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
@Inject(GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN)
private readonly getDriversLeaderboardUseCase: GetDriversLeaderboardUseCase,
@Inject(GET_TOTAL_DRIVERS_USE_CASE_TOKEN)
private readonly getTotalDriversUseCase: GetTotalDriversUseCase,
@Inject(COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN)
private readonly completeDriverOnboardingUseCase: CompleteDriverOnboardingUseCase,
@Inject(IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN)
private readonly isDriverRegisteredForRaceUseCase: IsDriverRegisteredForRaceUseCase,
@Inject(UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN)
private readonly updateDriverProfileUseCase: UpdateDriverProfileUseCase,
@Inject(GET_PROFILE_OVERVIEW_USE_CASE_TOKEN)
private readonly getProfileOverviewUseCase: GetProfileOverviewUseCase,
@Inject(DRIVER_REPOSITORY_TOKEN)
private readonly driverRepository: IDriverRepository,
@Inject(LOGGER_TOKEN)
private readonly logger: Logger,
) {}
async getDriversLeaderboard(): Promise<DriversLeaderboardDTO> {
async getDriversLeaderboard(): Promise<DriversLeaderboardPresenter> {
this.logger.debug('[DriverService] Fetching drivers leaderboard.');
const result = await this.getDriversLeaderboardUseCase.execute();
if (result.isOk()) {
return result.value as DriversLeaderboardDTO;
} else {
throw new Error(`Failed to fetch drivers leaderboard: ${result.error.details.message}`);
if (result.isErr()) {
throw new Error(`Failed to fetch drivers leaderboard: ${result.unwrapErr().details.message}`);
}
const presenter = new DriversLeaderboardPresenter();
presenter.reset();
presenter.present(result.unwrap());
return presenter;
}
async getTotalDrivers(): Promise<DriverStatsDTO> {
async getTotalDrivers(): Promise<DriverStatsPresenter> {
this.logger.debug('[DriverService] Fetching total drivers count.');
const result = await this.getTotalDriversUseCase.execute();
@@ -58,11 +76,12 @@ export class DriverService {
}
const presenter = new DriverStatsPresenter();
presenter.reset();
presenter.present(result.unwrap());
return presenter.viewModel;
return presenter;
}
async completeOnboarding(userId: string, input: CompleteOnboardingInputDTO): Promise<CompleteOnboardingOutputDTO> {
async completeOnboarding(userId: string, input: CompleteOnboardingInputDTO): Promise<CompleteOnboardingPresenter> {
this.logger.debug('Completing onboarding for user:', userId);
const result = await this.completeDriverOnboardingUseCase.execute({
@@ -75,80 +94,88 @@ export class DriverService {
bio: input.bio,
});
const presenter = new CompleteOnboardingPresenter();
presenter.reset();
if (result.isOk()) {
return {
success: true,
driverId: result.value.driverId,
};
presenter.present(result.value);
} else {
return {
success: false,
errorMessage: result.error.code,
};
presenter.presentError(result.error.code);
}
return presenter;
}
async getDriverRegistrationStatus(query: GetDriverRegistrationStatusQueryDTO): Promise<DriverRegistrationStatusDTO> {
async getDriverRegistrationStatus(
query: GetDriverRegistrationStatusQueryDTO,
): Promise<DriverRegistrationStatusPresenter> {
this.logger.debug('Checking driver registration status:', query);
const result = await this.isDriverRegisteredForRaceUseCase.execute({ raceId: query.raceId, driverId: query.driverId });
if (result.isOk()) {
return result.value;
} else {
// For now, throw error or handle appropriately. Since it's a query, perhaps return default or throw.
throw new Error(`Failed to check registration status: ${result.error.code}`);
const result = await this.isDriverRegisteredForRaceUseCase.execute({
raceId: query.raceId,
driverId: query.driverId,
});
if (result.isErr()) {
throw new Error(`Failed to check registration status: ${result.unwrapErr().code}`);
}
const presenter = new DriverRegistrationStatusPresenter();
presenter.reset();
const output = result.unwrap();
presenter.present(output.isRegistered, output.raceId, output.driverId);
return presenter;
}
async getCurrentDriver(userId: string): Promise<GetDriverOutputDTO | null> {
async getCurrentDriver(userId: string): Promise<DriverPresenter> {
this.logger.debug(`[DriverService] Fetching current driver for userId: ${userId}`);
const driver = await this.driverRepository.findById(userId);
if (!driver) {
return null;
}
return {
id: driver.id,
iracingId: driver.iracingId.value,
name: driver.name.value,
country: driver.country.value,
bio: driver.bio?.value,
joinedAt: driver.joinedAt.toISOString(),
};
const presenter = new DriverPresenter();
presenter.reset();
presenter.present(driver ?? null);
return presenter;
}
async updateDriverProfile(driverId: string, bio?: string, country?: string): Promise<GetDriverOutputDTO | null> {
async updateDriverProfile(
driverId: string,
bio?: string,
country?: string,
): Promise<DriverPresenter> {
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}`);
return null;
presenter.present(null);
return presenter;
}
return result.value;
presenter.present(result.value);
return presenter;
}
async getDriver(driverId: string): Promise<GetDriverOutputDTO | null> {
async getDriver(driverId: string): Promise<DriverPresenter> {
this.logger.debug(`[DriverService] Fetching driver for driverId: ${driverId}`);
const driver = await this.driverRepository.findById(driverId);
if (!driver) {
return null;
}
return {
id: driver.id,
iracingId: driver.iracingId.value,
name: driver.name.value,
country: driver.country.value,
bio: driver.bio?.value,
joinedAt: driver.joinedAt.toISOString(),
};
const presenter = new DriverPresenter();
presenter.reset();
presenter.present(driver ?? null);
return presenter;
}
async getDriverProfile(driverId: string): Promise<GetDriverProfileOutputDTO> {
async getDriverProfile(driverId: string): Promise<DriverProfilePresenter> {
this.logger.debug(`[DriverService] Fetching driver profile for driverId: ${driverId}`);
const result = await this.getProfileOverviewUseCase.execute({ driverId });
@@ -156,37 +183,10 @@ export class DriverService {
throw new Error(`Failed to fetch driver profile: ${result.error.code}`);
}
const outputPort = result.value;
return this.mapProfileOverviewToDTO(outputPort);
}
const presenter = new DriverProfilePresenter();
presenter.reset();
presenter.present(result.value);
private mapProfileOverviewToDTO(outputPort: ProfileOverviewOutputPort): GetDriverProfileOutputDTO {
return {
currentDriver: outputPort.driver ? {
id: outputPort.driver.id,
name: outputPort.driver.name,
country: outputPort.driver.country,
avatarUrl: outputPort.driver.avatarUrl,
iracingId: outputPort.driver.iracingId,
joinedAt: outputPort.driver.joinedAt.toISOString(),
rating: outputPort.driver.rating,
globalRank: outputPort.driver.globalRank,
consistency: outputPort.driver.consistency,
bio: outputPort.driver.bio,
totalDrivers: outputPort.driver.totalDrivers,
} : null,
stats: outputPort.stats,
finishDistribution: outputPort.finishDistribution,
teamMemberships: outputPort.teamMemberships.map(membership => ({
teamId: membership.teamId,
teamName: membership.teamName,
teamTag: membership.teamTag,
role: membership.role,
joinedAt: membership.joinedAt.toISOString(),
isCurrent: membership.isCurrent,
})),
socialSummary: outputPort.socialSummary,
extendedProfile: outputPort.extendedProfile,
};
return presenter;
}
}

View File

@@ -0,0 +1,29 @@
import type { CompleteDriverOnboardingOutputPort } from '@core/racing/application/ports/output/CompleteDriverOnboardingOutputPort';
import type { CompleteOnboardingOutputDTO } from '../dtos/CompleteOnboardingOutputDTO';
export class CompleteOnboardingPresenter {
private result: CompleteOnboardingOutputDTO | null = null;
reset(): void {
this.result = null;
}
present(output: CompleteDriverOnboardingOutputPort): void {
this.result = {
success: true,
driverId: output.driverId,
};
}
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;
}
}

View File

@@ -0,0 +1,30 @@
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;
}
present(driver: Driver | null): void {
if (!driver) {
this.result = null;
return;
}
this.result = {
id: driver.id,
iracingId: driver.iracingId.toString(),
name: driver.name.toString(),
country: driver.country.toString(),
bio: driver.bio?.toString(),
joinedAt: driver.joinedAt.toDate().toISOString(),
};
}
get viewModel(): GetDriverOutputDTO | null {
return this.result;
}
}

View File

@@ -0,0 +1,47 @@
import type { ProfileOverviewOutputPort } from '@core/racing/application/ports/output/ProfileOverviewOutputPort';
import type { GetDriverProfileOutputDTO } from '../dtos/GetDriverProfileOutputDTO';
export class DriverProfilePresenter {
private result: GetDriverProfileOutputDTO | null = null;
reset(): void {
this.result = null;
}
present(output: ProfileOverviewOutputPort): void {
this.result = {
currentDriver: output.driver
? {
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,
}
: 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,
})),
socialSummary: output.socialSummary,
extendedProfile: output.extendedProfile,
};
}
get viewModel(): GetDriverProfileOutputDTO {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,25 @@
import { DriverRegistrationStatusDTO } from '../dtos/DriverRegistrationStatusDTO';
export class DriverRegistrationStatusPresenter {
private result: DriverRegistrationStatusDTO | null = null;
reset(): void {
this.result = null;
}
present(isRegistered: boolean, raceId: string, driverId: string): void {
this.result = {
isRegistered,
raceId,
driverId,
};
}
get viewModel(): DriverRegistrationStatusDTO {
if (!this.result) {
throw new Error('Presenter not presented');
}
return this.result;
}
}

View File

@@ -1,51 +1,31 @@
import { DriversLeaderboardDTO } from '../dtos/DriversLeaderboardDTO';
import { DriverLeaderboardItemDTO } from '../dtos/DriverLeaderboardItemDTO';
import type { IDriversLeaderboardPresenter, DriversLeaderboardResultDTO } from '../../../../../core/racing/application/presenters/IDriversLeaderboardPresenter';
import { SkillLevelService } from '../../../../../core/racing/domain/services/SkillLevelService';
import type { DriversLeaderboardOutputPort } from '../../../../../core/racing/application/ports/output/DriversLeaderboardOutputPort';
export class DriversLeaderboardPresenter implements IDriversLeaderboardPresenter {
export class DriversLeaderboardPresenter {
private result: DriversLeaderboardDTO | null = null;
reset() {
reset(): void {
this.result = null;
}
present(dto: DriversLeaderboardResultDTO) {
const drivers: DriverLeaderboardItemDTO[] = dto.drivers.map(driver => {
const ranking = dto.rankings.find(r => r.driverId === driver.id);
const stats = dto.stats[driver.id];
const avatarUrl = dto.avatarUrls[driver.id];
const rating = ranking?.rating ?? 0;
const racesCompleted = stats?.racesCompleted ?? 0;
return {
present(output: DriversLeaderboardOutputPort): void {
this.result = {
drivers: output.drivers.map(driver => ({
id: driver.id,
name: driver.name,
rating,
// Use core SkillLevelService to derive band from rating
skillLevel: SkillLevelService.getSkillLevel(rating),
nationality: driver.country,
racesCompleted,
wins: stats?.wins ?? 0,
podiums: stats?.podiums ?? 0,
// Consider a driver active if they have completed at least one race
isActive: racesCompleted > 0,
rank: ranking?.overallRank ?? 0,
avatarUrl,
};
});
// Calculate totals
const totalRaces = drivers.reduce((sum, d) => sum + (d.racesCompleted ?? 0), 0);
const totalWins = drivers.reduce((sum, d) => sum + (d.wins ?? 0), 0);
const activeCount = drivers.filter(d => d.isActive).length;
this.result = {
drivers: drivers.sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0)),
totalRaces,
totalWins,
activeCount,
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,
})),
totalRaces: output.totalRaces,
totalWins: output.totalWins,
activeCount: output.activeCount,
};
}

View File

@@ -13,30 +13,37 @@ import { LeagueAdminDTO } from './dtos/LeagueAdminDTO';
import { LeagueAdminPermissionsDTO } from './dtos/LeagueAdminPermissionsDTO';
import { LeagueAdminProtestsDTO } from './dtos/LeagueAdminProtestsDTO';
import { LeagueMembershipsDTO } from './dtos/LeagueMembershipsDTO';
import { LeagueOwnerSummaryDTO } from './dtos/LeagueOwnerSummaryDTO';
import { LeagueScheduleDTO } from './dtos/LeagueScheduleDTO';
import { LeagueSeasonSummaryDTO } from './dtos/LeagueSeasonSummaryDTO';
import { LeagueConfigFormModelDTO } from './dtos/LeagueConfigFormModelDTO';
import { LeagueStatsDTO } from './dtos/LeagueStatsDTO';
import { LeagueStandingsDTO } from './dtos/LeagueStandingsDTO';
import { GetLeagueWalletOutputDTO } from './dtos/GetLeagueWalletOutputDTO';
import { WithdrawFromLeagueWalletInputDTO } from './dtos/WithdrawFromLeagueWalletInputDTO';
import { WithdrawFromLeagueWalletOutputDTO } from './dtos/WithdrawFromLeagueWalletOutputDTO';
import { GetSeasonSponsorshipsOutputDTO } from './dtos/GetSeasonSponsorshipsOutputDTO';
import { GetLeagueRacesOutputDTO } from './dtos/GetLeagueRacesOutputDTO';
import { RejectJoinRequestOutputDTO } from './dtos/RejectJoinRequestOutputDTO';
import { RemoveLeagueMemberOutputDTO } from './dtos/RemoveLeagueMemberOutputDTO';
import { TransferLeagueOwnershipOutputDTO } from './dtos/TransferLeagueOwnershipOutputDTO';
import { UpdateLeagueMemberRoleOutputDTO } from './dtos/UpdateLeagueMemberRoleOutputDTO';
import { LeagueJoinRequestWithDriverDTO } from './dtos/LeagueJoinRequestWithDriverDTO';
import { ApproveLeagueJoinRequestDTO } from './dtos/ApproveLeagueJoinRequestDTO';
// Core imports for view models
import type { LeagueScoringConfigViewModel } from './presenters/LeagueScoringConfigPresenter';
import type { LeagueScoringPresetsViewModel } from './presenters/LeagueScoringPresetsPresenter';
import type { AllLeaguesWithCapacityDTO as AllLeaguesWithCapacityViewModel } from '../dtos/AllLeaguesWithCapacityDTO';
import type { GetLeagueJoinRequestsViewModel } from '@core/racing/application/presenters/IGetLeagueJoinRequestsPresenter';
import type { AllLeaguesWithCapacityDTO as AllLeaguesWithCapacityViewModel } from './dtos/AllLeaguesWithCapacityDTO';
import { TotalLeaguesDTO } from './dtos/TotalLeaguesDTO';
import type { ApproveLeagueJoinRequestDTO } from './dtos/ApproveLeagueJoinRequestDTO';
import type { JoinLeagueOutputDTO } from './dtos/JoinLeagueOutputDTO';
import type { GetLeagueAdminPermissionsViewModel } from '@core/racing/application/presenters/IGetLeagueAdminPermissionsPresenter';
import type { CreateLeagueViewModel } from './dtos/CreateLeagueDTO';
// Core imports
import type { Logger } from '@core/shared/application/Logger';
// Use cases
import { GetLeagueStandingsUseCase } from '@core/league/application/use-cases/GetLeagueStandingsUseCase';
import { GetLeagueStandingsUseCase } from '@core/racing/application/use-cases/GetLeagueStandingsUseCase';
import { ApproveLeagueJoinRequestUseCase } from '@core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase';
import { CreateLeagueWithSeasonAndScoringUseCase } from '@core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase';
import { GetAllLeaguesWithCapacityUseCase } from '@core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase';
@@ -67,22 +74,26 @@ import { AllLeaguesWithCapacityPresenter } from './presenters/AllLeaguesWithCapa
import { TotalLeaguesPresenter } from './presenters/TotalLeaguesPresenter';
import { LeagueScoringConfigPresenter } from './presenters/LeagueScoringConfigPresenter';
import { LeagueScoringPresetsPresenter } from './presenters/LeagueScoringPresetsPresenter';
import { mapApproveLeagueJoinRequestPortToDTO } from './presenters/ApproveLeagueJoinRequestPresenter';
import { ApproveLeagueJoinRequestPresenter } from './presenters/ApproveLeagueJoinRequestPresenter';
import { GetLeagueAdminPermissionsPresenter } from './presenters/GetLeagueAdminPermissionsPresenter';
import { mapGetLeagueOwnerSummaryOutputPortToDTO } from './presenters/GetLeagueOwnerSummaryPresenter';
import { GetLeagueOwnerSummaryPresenter } from './presenters/GetLeagueOwnerSummaryPresenter';
import { LeagueJoinRequestsPresenter } from './presenters/LeagueJoinRequestsPresenter';
import { mapGetLeagueScheduleOutputPortToDTO, mapGetLeagueScheduleOutputPortToRaceDTOs } from './presenters/LeagueSchedulePresenter';
import { LeagueSchedulePresenter, LeagueRacesPresenter } from './presenters/LeagueSchedulePresenter';
import { LeagueStatsPresenter } from './presenters/LeagueStatsPresenter';
import { mapRejectLeagueJoinRequestOutputPortToDTO } from './presenters/RejectLeagueJoinRequestPresenter';
import { mapRemoveLeagueMemberOutputPortToDTO } from './presenters/RemoveLeagueMemberPresenter';
import { mapUpdateLeagueMemberRoleOutputPortToDTO } from './presenters/UpdateLeagueMemberRolePresenter';
import { RejectLeagueJoinRequestPresenter } from './presenters/RejectLeagueJoinRequestPresenter';
import { RemoveLeagueMemberPresenter } from './presenters/RemoveLeagueMemberPresenter';
import { UpdateLeagueMemberRolePresenter } from './presenters/UpdateLeagueMemberRolePresenter';
import { CreateLeaguePresenter } from './presenters/CreateLeaguePresenter';
import { mapJoinLeagueOutputPortToDTO } from './presenters/JoinLeaguePresenter';
import { mapTransferLeagueOwnershipOutputPortToDTO } from './presenters/TransferLeagueOwnershipPresenter';
import { mapGetLeagueProtestsOutputPortToDTO } from './presenters/GetLeagueProtestsPresenter';
import { mapGetLeagueSeasonsOutputPortToDTO } from './presenters/GetLeagueSeasonsPresenter';
import { JoinLeaguePresenter } from './presenters/JoinLeaguePresenter';
import { TransferLeagueOwnershipPresenter } from './presenters/TransferLeagueOwnershipPresenter';
import { GetLeagueProtestsPresenter } from './presenters/GetLeagueProtestsPresenter';
import { GetLeagueSeasonsPresenter } from './presenters/GetLeagueSeasonsPresenter';
import { LeagueConfigPresenter } from './presenters/LeagueConfigPresenter';
import { LeagueStandingsPresenter } from './presenters/LeagueStandingsPresenter';
import { GetLeagueMembershipsPresenter } from './presenters/GetLeagueMembershipsPresenter';
import { LeagueOwnerSummaryPresenter } from './presenters/LeagueOwnerSummaryPresenter';
import { LeagueAdminPresenter } from './presenters/LeagueAdminPresenter';
import { GetSeasonSponsorshipsPresenter } from './presenters/GetSeasonSponsorshipsPresenter';
// Tokens
import { LOGGER_TOKEN } from './LeagueProviders';
@@ -140,7 +151,7 @@ export class LeagueService {
return presenter.getViewModel()!;
}
async getLeagueJoinRequests(leagueId: string): Promise<GetLeagueJoinRequestsViewModel> {
async getLeagueJoinRequests(leagueId: string): Promise<LeagueJoinRequestWithDriverDTO[]> {
this.logger.debug(`[LeagueService] Fetching join requests for league: ${leagueId}.`);
const result = await this.getLeagueJoinRequestsUseCase.execute({ leagueId });
if (result.isErr()) {
@@ -148,7 +159,7 @@ export class LeagueService {
}
const presenter = new LeagueJoinRequestsPresenter();
presenter.present(result.unwrap());
return presenter.getViewModel();
return presenter.getViewModel()!.joinRequests;
}
async approveLeagueJoinRequest(input: ApproveJoinRequestInputDTO): Promise<ApproveLeagueJoinRequestDTO> {
@@ -157,19 +168,27 @@ export class LeagueService {
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
return mapApproveLeagueJoinRequestPortToDTO(result.unwrap());
const presenter = new ApproveLeagueJoinRequestPresenter();
presenter.present(result.unwrap());
return presenter.getViewModel()!;
}
async rejectLeagueJoinRequest(input: RejectJoinRequestInputDTO): Promise<RejectJoinRequestOutputDTO> {
this.logger.debug('Rejecting join request:', input);
const result = await this.rejectLeagueJoinRequestUseCase.execute({ requestId: input.requestId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
const error = result.unwrapErr();
return {
success: false,
error: error.code,
};
}
return mapRejectLeagueJoinRequestOutputPortToDTO(result.unwrap());
const presenter = new RejectLeagueJoinRequestPresenter();
presenter.present(result.unwrap());
return presenter.getViewModel()!;
}
async getLeagueAdminPermissions(query: GetLeagueAdminPermissionsInputDTO): Promise<GetLeagueAdminPermissionsViewModel> {
async getLeagueAdminPermissions(query: GetLeagueAdminPermissionsInputDTO): Promise<LeagueAdminPermissionsDTO> {
this.logger.debug('Getting league admin permissions', { query });
const result = await this.getLeagueAdminPermissionsUseCase.execute({ leagueId: query.leagueId, performerDriverId: query.performerDriverId });
// This use case never errors
@@ -182,27 +201,41 @@ export class LeagueService {
this.logger.debug('Removing league member', { leagueId: input.leagueId, targetDriverId: input.targetDriverId });
const result = await this.removeLeagueMemberUseCase.execute({ leagueId: input.leagueId, targetDriverId: input.targetDriverId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
const error = result.unwrapErr();
return {
success: false,
error: error.code,
};
}
return mapRemoveLeagueMemberOutputPortToDTO(result.unwrap());
const presenter = new RemoveLeagueMemberPresenter();
presenter.present(result.unwrap());
return presenter.getViewModel()!;
}
async updateLeagueMemberRole(input: UpdateLeagueMemberRoleInputDTO): Promise<UpdateLeagueMemberRoleOutputDTO> {
this.logger.debug('Updating league member role', { leagueId: input.leagueId, targetDriverId: input.targetDriverId, newRole: input.newRole });
const result = await this.updateLeagueMemberRoleUseCase.execute({ leagueId: input.leagueId, targetDriverId: input.targetDriverId, newRole: input.newRole });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
const error = result.unwrapErr();
return {
success: false,
error: error.code,
};
}
return mapUpdateLeagueMemberRoleOutputPortToDTO(result.unwrap());
const presenter = new UpdateLeagueMemberRolePresenter();
presenter.present(result.unwrap());
return presenter.getViewModel()!;
}
async getLeagueOwnerSummary(query: GetLeagueOwnerSummaryQueryDTO): Promise<LeagueOwnerSummaryDTO | null> {
async getLeagueOwnerSummary(query: GetLeagueOwnerSummaryQueryDTO): Promise<LeagueOwnerSummaryDTO> {
this.logger.debug('Getting league owner summary:', query);
const result = await this.getLeagueOwnerSummaryUseCase.execute({ ownerId: query.ownerId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
return mapGetLeagueOwnerSummaryOutputPortToDTO(result.unwrap());
const presenter = new GetLeagueOwnerSummaryPresenter();
presenter.present(result.unwrap());
return presenter.getViewModel()!;
}
async getLeagueFullConfig(query: GetLeagueAdminConfigQueryDTO): Promise<LeagueConfigFormModelDTO | null> {
@@ -229,7 +262,9 @@ export class LeagueService {
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
return mapGetLeagueProtestsOutputPortToDTO(result.unwrap());
const presenter = new GetLeagueProtestsPresenter();
presenter.present(result.unwrap());
return presenter.getViewModel()!;
}
async getLeagueSeasons(query: GetLeagueSeasonsQueryDTO): Promise<LeagueSeasonSummaryDTO[]> {
@@ -238,7 +273,9 @@ export class LeagueService {
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
return mapGetLeagueSeasonsOutputPortToDTO(result.unwrap());
const presenter = new GetLeagueSeasonsPresenter();
presenter.present(result.unwrap());
return presenter.getViewModel()!;
}
async getLeagueMemberships(leagueId: string): Promise<LeagueMembershipsDTO> {
@@ -249,7 +286,7 @@ export class LeagueService {
}
const presenter = new GetLeagueMembershipsPresenter();
presenter.present(result.unwrap());
return presenter.getViewModel().memberships as LeagueMembershipsDTO;
return presenter.getViewModel()!.memberships;
}
async getLeagueStandings(leagueId: string): Promise<LeagueStandingsDTO> {
@@ -260,7 +297,7 @@ export class LeagueService {
}
const presenter = new LeagueStandingsPresenter();
presenter.present(result.unwrap());
return presenter.getViewModel();
return presenter.getViewModel()!;
}
async getLeagueSchedule(leagueId: string): Promise<LeagueScheduleDTO> {
@@ -279,7 +316,9 @@ export class LeagueService {
? leagueConfigResult.unwrap().league.name.toString()
: undefined;
return mapGetLeagueScheduleOutputPortToDTO(scheduleResult.unwrap(), leagueName);
const presenter = new LeagueSchedulePresenter();
presenter.present(scheduleResult.unwrap(), leagueName);
return presenter.getViewModel()!;
}
async getLeagueStats(leagueId: string): Promise<LeagueStatsDTO> {
@@ -315,7 +354,9 @@ export class LeagueService {
throw new Error(ownerSummaryResult.unwrapErr().code);
}
const ownerSummary = mapGetLeagueOwnerSummaryOutputPortToDTO(ownerSummaryResult.unwrap());
const ownerSummaryPresenter = new GetLeagueOwnerSummaryPresenter();
ownerSummaryPresenter.present(ownerSummaryResult.unwrap());
const ownerSummary = ownerSummaryPresenter.getViewModel()!;
const configPresenter = new LeagueConfigPresenter();
configPresenter.present(fullConfig);
@@ -323,7 +364,7 @@ export class LeagueService {
const adminPresenter = new LeagueAdminPresenter();
adminPresenter.present({
joinRequests: joinRequests.joinRequests,
joinRequests: joinRequests,
ownerSummary,
config: configForm,
protests,
@@ -358,7 +399,7 @@ export class LeagueService {
}
const presenter = new CreateLeaguePresenter();
presenter.present(result.unwrap());
return presenter.getViewModel();
return presenter.getViewModel()!;
}
async getLeagueScoringConfig(leagueId: string): Promise<LeagueScoringConfigViewModel | null> {
@@ -371,7 +412,7 @@ export class LeagueService {
this.logger.error('Error getting league scoring config', new Error(result.unwrapErr().code));
return null;
}
await presenter.present(result.unwrap());
presenter.present(result.unwrap());
return presenter.getViewModel();
} catch (error) {
this.logger.error('Error getting league scoring config', error instanceof Error ? error : new Error(String(error)));
@@ -403,7 +444,9 @@ export class LeagueService {
error: error.code,
};
}
return mapJoinLeagueOutputPortToDTO(result.unwrap());
const presenter = new JoinLeaguePresenter();
presenter.present(result.unwrap());
return presenter.getViewModel()!;
}
async transferLeagueOwnership(leagueId: string, currentOwnerId: string, newOwnerId: string): Promise<TransferLeagueOwnershipOutputDTO> {
@@ -417,7 +460,9 @@ export class LeagueService {
error: error.code,
};
}
return mapTransferLeagueOwnershipOutputPortToDTO(result.unwrap());
const presenter = new TransferLeagueOwnershipPresenter();
presenter.present(result.unwrap());
return presenter.getViewModel()!;
}
async getSeasonSponsorships(seasonId: string): Promise<GetSeasonSponsorshipsOutputDTO> {
@@ -428,11 +473,9 @@ export class LeagueService {
throw new Error(result.unwrapErr().code);
}
const value = result.unwrap();
return {
sponsorships: value?.sponsorships ?? [],
};
const presenter = new GetSeasonSponsorshipsPresenter();
presenter.present(result.unwrap());
return presenter.getViewModel()!;
}
async getRaces(leagueId: string): Promise<GetLeagueRacesOutputDTO> {
@@ -443,10 +486,11 @@ export class LeagueService {
throw new Error(result.unwrapErr().code);
}
const races = mapGetLeagueScheduleOutputPortToRaceDTOs(result.unwrap());
const presenter = new LeagueRacesPresenter();
presenter.present(result.unwrap());
return {
races,
races: presenter.getViewModel()!,
};
}
@@ -454,7 +498,7 @@ export class LeagueService {
this.logger.debug('Getting league wallet', { leagueId });
const result = await this.getLeagueWalletUseCase.execute({ leagueId });
if (result.isErr()) {
throw new Error(result.unwrapErr().message);
throw new Error(result.unwrapErr().code);
}
return result.unwrap();
}
@@ -471,9 +515,9 @@ export class LeagueService {
if (result.isErr()) {
const error = result.unwrapErr();
if (error.code === 'WITHDRAWAL_NOT_ALLOWED') {
return { success: false, message: error.message };
return { success: false, message: error.code };
}
throw new Error(error.message);
throw new Error(error.code);
}
return result.unwrap();
}

View File

@@ -1,7 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsDate, IsOptional, ValidateNested } from 'class-validator';
import { IsString, IsDate, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
import { DriverDto } from '../../driver/dto/DriverDto';
export class LeagueJoinRequestDTO {
@ApiProperty()
@@ -26,9 +25,13 @@ export class LeagueJoinRequestDTO {
@IsString()
message?: string;
@ApiProperty({ type: () => DriverDto, required: false })
@ApiProperty({
required: false,
type: () => Object,
})
@IsOptional()
@ValidateNested()
@Type(() => DriverDto)
driver?: DriverDto;
driver?: {
id: string;
name: string;
};
}

View File

@@ -1,8 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean } from 'class-validator';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
export class RemoveLeagueMemberOutputDTO {
@ApiProperty()
@IsBoolean()
success: boolean;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
error?: string;
}

View File

@@ -1,8 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean } from 'class-validator';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
export class UpdateLeagueMemberRoleOutputDTO {
@ApiProperty()
@IsBoolean()
success: boolean;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
error?: string;
}

View File

@@ -1,6 +1,18 @@
import type { ApproveLeagueJoinRequestResultPort } from '@core/racing/application/ports/output/ApproveLeagueJoinRequestResultPort';
import type { ApproveLeagueJoinRequestDTO } from '../dtos/ApproveLeagueJoinRequestDTO';
export function mapApproveLeagueJoinRequestPortToDTO(port: ApproveLeagueJoinRequestResultPort): ApproveLeagueJoinRequestDTO {
return port;
export class ApproveLeagueJoinRequestPresenter {
private result: ApproveLeagueJoinRequestDTO | null = null;
reset() {
this.result = null;
}
present(output: ApproveLeagueJoinRequestResultPort) {
this.result = output;
}
getViewModel(): ApproveLeagueJoinRequestDTO | null {
return this.result;
}
}

View File

@@ -0,0 +1,22 @@
import type { GetLeagueAdminPermissionsOutputPort } from '@core/racing/application/ports/output/GetLeagueAdminPermissionsOutputPort';
import { LeagueAdminPermissionsDTO } from '../dtos/LeagueAdminPermissionsDTO';
import type { Presenter } from '@core/shared/presentation';
export class GetLeagueAdminPermissionsPresenter implements Presenter<GetLeagueAdminPermissionsOutputPort, LeagueAdminPermissionsDTO> {
private result: LeagueAdminPermissionsDTO | null = null;
reset(): void {
this.result = null;
}
present(port: GetLeagueAdminPermissionsOutputPort): void {
this.result = {
canRemoveMember: port.canRemoveMember,
canUpdateRoles: port.canUpdateRoles,
};
}
getViewModel(): LeagueAdminPermissionsDTO | null {
return this.result;
}
}

View File

@@ -0,0 +1,28 @@
import { GetLeagueMembershipsPresenter } from './GetLeagueMembershipsPresenter';
import type { GetLeagueMembershipsOutputPort } from '@core/racing/application/ports/output/GetLeagueMembershipsOutputPort';
describe('GetLeagueMembershipsPresenter', () => {
it('presents memberships correctly', () => {
const presenter = new GetLeagueMembershipsPresenter();
const output: GetLeagueMembershipsOutputPort = {
memberships: {
members: [
{
driverId: 'driver-1',
driver: { id: 'driver-1', name: 'John Doe' },
role: 'member',
joinedAt: new Date('2023-01-01'),
},
],
},
};
presenter.present(output);
const vm = presenter.getViewModel();
expect(vm).not.toBeNull();
expect(vm!.memberships.members).toHaveLength(1);
expect(vm!.memberships.members[0].driverId).toBe('driver-1');
expect(vm!.memberships.members[0].driver.name).toBe('John Doe');
});
});

View File

@@ -0,0 +1,32 @@
import type { GetLeagueMembershipsOutputPort } from '@core/racing/application/ports/output/GetLeagueMembershipsOutputPort';
import { LeagueMembershipsDTO, LeagueMemberDTO } from '../dtos/LeagueMembershipsDTO';
export interface GetLeagueMembershipsViewModel {
memberships: LeagueMembershipsDTO;
}
export class GetLeagueMembershipsPresenter {
private result: GetLeagueMembershipsViewModel | null = null;
reset() {
this.result = null;
}
present(output: GetLeagueMembershipsOutputPort) {
const members: LeagueMemberDTO[] = output.memberships.members.map(member => ({
driverId: member.driverId,
driver: member.driver,
role: member.role,
joinedAt: member.joinedAt,
}));
this.result = {
memberships: {
members,
},
};
}
getViewModel(): GetLeagueMembershipsViewModel | null {
return this.result;
}
}

View File

@@ -1,19 +1,34 @@
import { GetLeagueOwnerSummaryOutputPort } from '@core/racing/application/ports/output/GetLeagueOwnerSummaryOutputPort';
import { LeagueOwnerSummaryDTO } from '../dtos/LeagueOwnerSummaryDTO';
export function mapGetLeagueOwnerSummaryOutputPortToDTO(output: GetLeagueOwnerSummaryOutputPort): LeagueOwnerSummaryDTO | null {
if (!output.summary) return null;
export class GetLeagueOwnerSummaryPresenter {
private result: LeagueOwnerSummaryDTO | null = null;
return {
driver: {
id: output.summary.driver.id,
iracingId: output.summary.driver.iracingId,
name: output.summary.driver.name,
country: output.summary.driver.country,
bio: output.summary.driver.bio,
joinedAt: output.summary.driver.joinedAt,
},
rating: output.summary.rating,
rank: output.summary.rank,
};
reset() {
this.result = null;
}
present(output: GetLeagueOwnerSummaryOutputPort) {
if (!output.summary) {
this.result = null;
return;
}
this.result = {
driver: {
id: output.summary.driver.id,
iracingId: output.summary.driver.iracingId,
name: output.summary.driver.name,
country: output.summary.driver.country,
bio: output.summary.driver.bio,
joinedAt: output.summary.driver.joinedAt,
},
rating: output.summary.rating,
rank: output.summary.rank,
};
}
getViewModel(): LeagueOwnerSummaryDTO | null {
return this.result;
}
}

View File

@@ -20,49 +20,65 @@ function mapProtestStatus(status: ProtestOutputPort['status']): ProtestDTO['stat
}
}
export function mapGetLeagueProtestsOutputPortToDTO(output: GetLeagueProtestsOutputPort, leagueName?: string): LeagueAdminProtestsDTO {
const protests: ProtestDTO[] = output.protests.map((protest) => {
const race = output.racesById[protest.raceId];
export class GetLeagueProtestsPresenter {
private result: LeagueAdminProtestsDTO | null = null;
return {
id: protest.id,
leagueId: race?.leagueId,
raceId: protest.raceId,
protestingDriverId: protest.protestingDriverId,
accusedDriverId: protest.accusedDriverId,
submittedAt: new Date(protest.filedAt),
description: protest.incident.description,
status: mapProtestStatus(protest.status),
};
});
reset() {
this.result = null;
}
const racesById: { [raceId: string]: RaceDTO } = {};
for (const raceId in output.racesById) {
const race = output.racesById[raceId];
racesById[raceId] = {
id: race.id,
name: race.track,
date: race.scheduledAt,
leagueName,
present(output: GetLeagueProtestsOutputPort, leagueName?: string) {
const protests: ProtestDTO[] = output.protests.map((protest) => {
const race = output.racesById[protest.raceId];
return {
id: protest.id,
leagueId: race?.leagueId || '',
raceId: protest.raceId,
protestingDriverId: protest.protestingDriverId,
accusedDriverId: protest.accusedDriverId,
submittedAt: new Date(protest.filedAt),
description: protest.incident.description,
status: mapProtestStatus(protest.status),
};
});
const racesById: { [raceId: string]: RaceDTO } = {};
for (const raceId in output.racesById) {
const race = output.racesById[raceId];
if (race) {
racesById[raceId] = {
id: race.id,
name: race.track,
date: race.scheduledAt.toISOString(),
leagueName,
};
}
}
const driversById: { [driverId: string]: DriverDTO } = {};
for (const driverId in output.driversById) {
const driver = output.driversById[driverId];
if (driver) {
driversById[driverId] = {
id: driver.id,
iracingId: driver.iracingId,
name: driver.name,
country: driver.country,
bio: driver.bio,
joinedAt: driver.joinedAt,
};
}
}
this.result = {
protests,
racesById,
driversById,
};
}
const driversById: { [driverId: string]: DriverDTO } = {};
for (const driverId in output.driversById) {
const driver = output.driversById[driverId];
driversById[driverId] = {
id: driver.id,
iracingId: driver.iracingId,
name: driver.name,
country: driver.country,
bio: driver.bio,
joinedAt: driver.joinedAt,
};
getViewModel(): LeagueAdminProtestsDTO | null {
return this.result;
}
return {
protests,
racesById,
driversById,
};
}

View File

@@ -1,14 +1,26 @@
import { GetLeagueSeasonsOutputPort } from '@core/racing/application/ports/output/GetLeagueSeasonsOutputPort';
import { LeagueSeasonSummaryDTO } from '../dtos/LeagueSeasonSummaryDTO';
export function mapGetLeagueSeasonsOutputPortToDTO(output: GetLeagueSeasonsOutputPort): LeagueSeasonSummaryDTO[] {
return output.seasons.map(season => ({
seasonId: season.seasonId,
name: season.name,
status: season.status,
startDate: season.startDate,
endDate: season.endDate,
isPrimary: season.isPrimary,
isParallelActive: season.isParallelActive,
}));
export class GetLeagueSeasonsPresenter {
private result: LeagueSeasonSummaryDTO[] | null = null;
reset() {
this.result = null;
}
present(output: GetLeagueSeasonsOutputPort) {
this.result = output.seasons.map(season => ({
seasonId: season.seasonId,
name: season.name,
status: season.status,
startDate: season.startDate,
endDate: season.endDate,
isPrimary: season.isPrimary,
isParallelActive: season.isParallelActive,
}));
}
getViewModel(): LeagueSeasonSummaryDTO[] | null {
return this.result;
}
}

View File

@@ -0,0 +1,20 @@
import type { GetSeasonSponsorshipsOutputPort } from '@core/racing/application/ports/output/GetSeasonSponsorshipsOutputPort';
import { GetSeasonSponsorshipsOutputDTO } from '../dtos/GetSeasonSponsorshipsOutputDTO';
export class GetSeasonSponsorshipsPresenter {
private result: GetSeasonSponsorshipsOutputDTO | null = null;
reset() {
this.result = null;
}
present(output: GetSeasonSponsorshipsOutputPort) {
this.result = {
sponsorships: output?.sponsorships ?? [],
};
}
getViewModel(): GetSeasonSponsorshipsOutputDTO | null {
return this.result;
}
}

View File

@@ -1,9 +1,21 @@
import type { JoinLeagueOutputPort } from '@core/racing/application/ports/output/JoinLeagueOutputPort';
import type { JoinLeagueOutputDTO } from '../dtos/JoinLeagueOutputDTO';
export function mapJoinLeagueOutputPortToDTO(port: JoinLeagueOutputPort): JoinLeagueOutputDTO {
return {
success: true,
membershipId: port.membershipId,
};
export class JoinLeaguePresenter {
private result: JoinLeagueOutputDTO | null = null;
reset() {
this.result = null;
}
present(output: JoinLeagueOutputPort) {
this.result = {
success: true,
membershipId: output.membershipId,
};
}
getViewModel(): JoinLeagueOutputDTO | null {
return this.result;
}
}

View File

@@ -0,0 +1,28 @@
import { LeagueJoinRequestsPresenter } from './LeagueJoinRequestsPresenter';
import type { GetLeagueJoinRequestsOutputPort } from '@core/racing/application/ports/output/GetLeagueJoinRequestsOutputPort';
describe('LeagueJoinRequestsPresenter', () => {
it('presents join requests correctly', () => {
const presenter = new LeagueJoinRequestsPresenter();
const output: GetLeagueJoinRequestsOutputPort = {
joinRequests: [
{
id: 'req-1',
leagueId: 'league-1',
driverId: 'driver-1',
requestedAt: new Date('2023-01-01'),
message: 'Please accept me',
driver: { id: 'driver-1', name: 'John Doe' },
},
],
};
presenter.present(output);
const vm = presenter.getViewModel();
expect(vm).not.toBeNull();
expect(vm!.joinRequests).toHaveLength(1);
expect(vm!.joinRequests[0].id).toBe('req-1');
expect(vm!.joinRequests[0].driver.name).toBe('John Doe');
});
});

View File

@@ -0,0 +1,32 @@
import type { GetLeagueJoinRequestsOutputPort } from '@core/racing/application/ports/output/GetLeagueJoinRequestsOutputPort';
import { LeagueJoinRequestWithDriverDTO } from '../dtos/LeagueJoinRequestWithDriverDTO';
export interface LeagueJoinRequestsViewModel {
joinRequests: LeagueJoinRequestWithDriverDTO[];
}
export class LeagueJoinRequestsPresenter {
private result: LeagueJoinRequestsViewModel | null = null;
reset() {
this.result = null;
}
present(output: GetLeagueJoinRequestsOutputPort) {
const joinRequests: LeagueJoinRequestWithDriverDTO[] = output.joinRequests.map(request => ({
id: request.id,
leagueId: request.leagueId,
driverId: request.driverId,
requestedAt: request.requestedAt,
message: request.message,
driver: request.driver,
}));
this.result = {
joinRequests,
};
}
getViewModel(): LeagueJoinRequestsViewModel | null {
return this.result;
}
}

View File

@@ -0,0 +1,42 @@
import { LeagueOwnerSummaryPresenter } from './LeagueOwnerSummaryPresenter';
import type { GetLeagueOwnerSummaryOutputPort } from '@core/racing/application/ports/output/GetLeagueOwnerSummaryOutputPort';
describe('LeagueOwnerSummaryPresenter', () => {
it('presents owner summary correctly', () => {
const presenter = new LeagueOwnerSummaryPresenter();
const output: GetLeagueOwnerSummaryOutputPort = {
summary: {
driver: {
id: 'driver-1',
iracingId: '12345',
name: 'John Doe',
country: 'US',
bio: 'Racing enthusiast',
joinedAt: '2023-01-01',
},
rating: 1500,
rank: 100,
},
};
presenter.present(output);
const vm = presenter.getViewModel();
expect(vm).not.toBeNull();
expect(vm!.driver.id).toBe('driver-1');
expect(vm!.rating).toBe(1500);
expect(vm!.rank).toBe(100);
});
it('handles null summary', () => {
const presenter = new LeagueOwnerSummaryPresenter();
const output: GetLeagueOwnerSummaryOutputPort = {
summary: null,
};
presenter.present(output);
const vm = presenter.getViewModel();
expect(vm).toBeNull();
});
});

View File

@@ -0,0 +1,33 @@
import type { GetLeagueOwnerSummaryOutputPort } from '@core/racing/application/ports/output/GetLeagueOwnerSummaryOutputPort';
import { LeagueOwnerSummaryDTO } from '../dtos/LeagueOwnerSummaryDTO';
export class LeagueOwnerSummaryPresenter {
private result: LeagueOwnerSummaryDTO | null = null;
reset() {
this.result = null;
}
present(output: GetLeagueOwnerSummaryOutputPort) {
if (!output.summary) {
this.result = null;
return;
}
this.result = {
driver: {
id: output.summary.driver.id,
iracingId: output.summary.driver.iracingId,
name: output.summary.driver.name,
country: output.summary.driver.country,
bio: output.summary.driver.bio,
joinedAt: output.summary.driver.joinedAt,
},
rating: output.summary.rating,
rank: output.summary.rank,
};
}
getViewModel(): LeagueOwnerSummaryDTO | null {
return this.result;
}
}

View File

@@ -2,22 +2,46 @@ import { GetLeagueScheduleOutputPort } from '@core/racing/application/ports/outp
import { LeagueScheduleDTO } from '../dtos/LeagueScheduleDTO';
import { RaceDTO } from '../../race/dtos/RaceDTO';
export function mapGetLeagueScheduleOutputPortToDTO(output: GetLeagueScheduleOutputPort, leagueName?: string): LeagueScheduleDTO {
return {
races: output.races.map<RaceDTO>(race => ({
export class LeagueSchedulePresenter {
private result: LeagueScheduleDTO | null = null;
reset() {
this.result = null;
}
present(output: GetLeagueScheduleOutputPort, leagueName?: string) {
this.result = {
races: output.races.map<RaceDTO>(race => ({
id: race.id,
name: race.name,
date: race.scheduledAt.toISOString(),
leagueName,
})),
};
}
getViewModel(): LeagueScheduleDTO | null {
return this.result;
}
}
export class LeagueRacesPresenter {
private result: RaceDTO[] | null = null;
reset() {
this.result = null;
}
present(output: GetLeagueScheduleOutputPort, leagueName?: string) {
this.result = output.races.map<RaceDTO>(race => ({
id: race.id,
name: race.name,
date: race.scheduledAt.toISOString(),
leagueName,
})),
};
}
}));
}
export function mapGetLeagueScheduleOutputPortToRaceDTOs(output: GetLeagueScheduleOutputPort, leagueName?: string): RaceDTO[] {
return output.races.map<RaceDTO>(race => ({
id: race.id,
name: race.name,
date: race.scheduledAt.toISOString(),
leagueName,
}));
getViewModel(): RaceDTO[] | null {
return this.result;
}
}

View File

@@ -1,9 +1,21 @@
import type { RejectLeagueJoinRequestOutputPort } from '@core/racing/application/ports/output/RejectLeagueJoinRequestOutputPort';
import type { RejectJoinRequestOutputDTO } from '../dtos/RejectJoinRequestOutputDTO';
export function mapRejectLeagueJoinRequestOutputPortToDTO(port: RejectLeagueJoinRequestOutputPort): RejectJoinRequestOutputDTO {
return {
success: port.success,
message: port.message,
};
export class RejectLeagueJoinRequestPresenter {
private result: RejectJoinRequestOutputDTO | null = null;
reset() {
this.result = null;
}
present(output: RejectLeagueJoinRequestOutputPort) {
this.result = {
success: output.success,
message: output.message,
};
}
getViewModel(): RejectJoinRequestOutputDTO | null {
return this.result;
}
}

View File

@@ -1,8 +1,20 @@
import type { RemoveLeagueMemberOutputPort } from '@core/racing/application/ports/output/RemoveLeagueMemberOutputPort';
import type { RemoveLeagueMemberOutputDTO } from '../dtos/RemoveLeagueMemberOutputDTO';
export function mapRemoveLeagueMemberOutputPortToDTO(port: RemoveLeagueMemberOutputPort): RemoveLeagueMemberOutputDTO {
return {
success: port.success,
};
export class RemoveLeagueMemberPresenter {
private result: RemoveLeagueMemberOutputDTO | null = null;
reset() {
this.result = null;
}
present(output: RemoveLeagueMemberOutputPort) {
this.result = {
success: output.success,
};
}
getViewModel(): RemoveLeagueMemberOutputDTO | null {
return this.result;
}
}

View File

@@ -1,8 +1,20 @@
import type { TransferLeagueOwnershipOutputPort } from '@core/racing/application/ports/output/TransferLeagueOwnershipOutputPort';
import type { TransferLeagueOwnershipOutputDTO } from '../dtos/TransferLeagueOwnershipOutputDTO';
export function mapTransferLeagueOwnershipOutputPortToDTO(port: TransferLeagueOwnershipOutputPort): TransferLeagueOwnershipOutputDTO {
return {
success: port.success,
};
export class TransferLeagueOwnershipPresenter {
private result: TransferLeagueOwnershipOutputDTO | null = null;
reset() {
this.result = null;
}
present(output: TransferLeagueOwnershipOutputPort) {
this.result = {
success: output.success,
};
}
getViewModel(): TransferLeagueOwnershipOutputDTO | null {
return this.result;
}
}

View File

@@ -1,8 +1,20 @@
import type { UpdateLeagueMemberRoleOutputPort } from '@core/racing/application/ports/output/UpdateLeagueMemberRoleOutputPort';
import type { UpdateLeagueMemberRoleOutputDTO } from '../dtos/UpdateLeagueMemberRoleOutputDTO';
export function mapUpdateLeagueMemberRoleOutputPortToDTO(port: UpdateLeagueMemberRoleOutputPort): UpdateLeagueMemberRoleOutputDTO {
return {
success: port.success,
};
export class UpdateLeagueMemberRolePresenter {
private result: UpdateLeagueMemberRoleOutputDTO | null = null;
reset() {
this.result = null;
}
present(output: UpdateLeagueMemberRoleOutputPort) {
this.result = {
success: output.success,
};
}
getViewModel(): UpdateLeagueMemberRoleOutputDTO | null {
return this.result;
}
}

View File

@@ -35,8 +35,8 @@ describe('MediaController', () => {
describe('requestAvatarGeneration', () => {
it('should request avatar generation and return 201 on success', async () => {
const input: RequestAvatarGenerationInputDTO = { driverId: 'driver-123' };
const result = { success: true, jobId: 'job-123' };
service.requestAvatarGeneration.mockResolvedValue(result);
const viewModel = { success: true, jobId: 'job-123' } as any;
service.requestAvatarGeneration.mockResolvedValue({ viewModel } as any);
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
@@ -47,13 +47,13 @@ describe('MediaController', () => {
expect(service.requestAvatarGeneration).toHaveBeenCalledWith(input);
expect(mockRes.status).toHaveBeenCalledWith(201);
expect(mockRes.json).toHaveBeenCalledWith(result);
expect(mockRes.json).toHaveBeenCalledWith(viewModel);
});
it('should return 400 on failure', async () => {
const input: RequestAvatarGenerationInputDTO = { driverId: 'driver-123' };
const result = { success: false, error: 'Error' };
service.requestAvatarGeneration.mockResolvedValue(result);
const viewModel = { success: false, error: 'Error' } as any;
service.requestAvatarGeneration.mockResolvedValue({ viewModel } as any);
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
@@ -63,7 +63,7 @@ describe('MediaController', () => {
await controller.requestAvatarGeneration(input, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith(result);
expect(mockRes.json).toHaveBeenCalledWith(viewModel);
});
});
@@ -71,8 +71,8 @@ describe('MediaController', () => {
it('should upload media and return 201 on success', async () => {
const file: Express.Multer.File = { filename: 'file.jpg' } as Express.Multer.File;
const input: UploadMediaInputDTO = { type: 'image' };
const result = { success: true, mediaId: 'media-123' };
service.uploadMedia.mockResolvedValue(result);
const viewModel = { success: true, mediaId: 'media-123' } as any;
service.uploadMedia.mockResolvedValue({ viewModel } as any);
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
@@ -83,15 +83,15 @@ describe('MediaController', () => {
expect(service.uploadMedia).toHaveBeenCalledWith({ ...input, file });
expect(mockRes.status).toHaveBeenCalledWith(201);
expect(mockRes.json).toHaveBeenCalledWith(result);
expect(mockRes.json).toHaveBeenCalledWith(viewModel);
});
});
describe('getMedia', () => {
it('should return media if found', async () => {
const mediaId = 'media-123';
const result = { id: mediaId, url: 'url' };
service.getMedia.mockResolvedValue(result);
const viewModel = { id: mediaId, url: 'url' } as any;
service.getMedia.mockResolvedValue({ viewModel } as any);
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
@@ -102,12 +102,12 @@ describe('MediaController', () => {
expect(service.getMedia).toHaveBeenCalledWith(mediaId);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith(result);
expect(mockRes.json).toHaveBeenCalledWith(viewModel);
});
it('should return 404 if not found', async () => {
const mediaId = 'media-123';
service.getMedia.mockResolvedValue(null);
service.getMedia.mockResolvedValue({ viewModel: null } as any);
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
@@ -124,8 +124,8 @@ describe('MediaController', () => {
describe('deleteMedia', () => {
it('should delete media', async () => {
const mediaId = 'media-123';
const result = { success: true };
service.deleteMedia.mockResolvedValue(result);
const viewModel = { success: true } as any;
service.deleteMedia.mockResolvedValue({ viewModel } as any);
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
@@ -136,7 +136,7 @@ describe('MediaController', () => {
expect(service.deleteMedia).toHaveBeenCalledWith(mediaId);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith(result);
expect(mockRes.json).toHaveBeenCalledWith(viewModel);
});
});

View File

@@ -29,11 +29,13 @@ export class MediaController {
@Body() input: RequestAvatarGenerationInput,
@Res() res: Response,
): Promise<void> {
const result = await this.mediaService.requestAvatarGeneration(input);
if (result.success) {
res.status(HttpStatus.CREATED).json(result);
const presenter = await this.mediaService.requestAvatarGeneration(input);
const viewModel = presenter.viewModel;
if (viewModel.success) {
res.status(HttpStatus.CREATED).json(viewModel);
} else {
res.status(HttpStatus.BAD_REQUEST).json(result);
res.status(HttpStatus.BAD_REQUEST).json(viewModel);
}
}
@@ -47,11 +49,13 @@ export class MediaController {
@Body() input: UploadMediaInput,
@Res() res: Response,
): Promise<void> {
const result = await this.mediaService.uploadMedia({ ...input, file });
if (result.success) {
res.status(HttpStatus.CREATED).json(result);
const presenter = await this.mediaService.uploadMedia({ ...input, file });
const viewModel = presenter.viewModel;
if (viewModel.success) {
res.status(HttpStatus.CREATED).json(viewModel);
} else {
res.status(HttpStatus.BAD_REQUEST).json(result);
res.status(HttpStatus.BAD_REQUEST).json(viewModel);
}
}
@@ -63,9 +67,11 @@ export class MediaController {
@Param('mediaId') mediaId: string,
@Res() res: Response,
): Promise<void> {
const result = await this.mediaService.getMedia(mediaId);
if (result) {
res.status(HttpStatus.OK).json(result);
const presenter = await this.mediaService.getMedia(mediaId);
const viewModel = presenter.viewModel;
if (viewModel) {
res.status(HttpStatus.OK).json(viewModel);
} else {
res.status(HttpStatus.NOT_FOUND).json({ error: 'Media not found' });
}
@@ -79,10 +85,12 @@ export class MediaController {
@Param('mediaId') mediaId: string,
@Res() res: Response,
): Promise<void> {
const result = await this.mediaService.deleteMedia(mediaId);
res.status(HttpStatus.OK).json(result);
}
const presenter = await this.mediaService.deleteMedia(mediaId);
const viewModel = presenter.viewModel;
res.status(HttpStatus.OK).json(viewModel);
}
@Get('avatar/:driverId')
@ApiOperation({ summary: 'Get avatar for driver' })
@ApiParam({ name: 'driverId', description: 'Driver ID' })
@@ -91,14 +99,16 @@ export class MediaController {
@Param('driverId') driverId: string,
@Res() res: Response,
): Promise<void> {
const result = await this.mediaService.getAvatar(driverId);
if (result) {
res.status(HttpStatus.OK).json(result);
const presenter = await this.mediaService.getAvatar(driverId);
const viewModel = presenter.viewModel;
if (viewModel) {
res.status(HttpStatus.OK).json(viewModel);
} else {
res.status(HttpStatus.NOT_FOUND).json({ error: 'Avatar not found' });
}
}
@Put('avatar/:driverId')
@ApiOperation({ summary: 'Update avatar for driver' })
@ApiParam({ name: 'driverId', description: 'Driver ID' })
@@ -108,7 +118,9 @@ export class MediaController {
@Body() input: UpdateAvatarInput,
@Res() res: Response,
): Promise<void> {
const result = await this.mediaService.updateAvatar(driverId, input);
res.status(HttpStatus.OK).json(result);
const presenter = await this.mediaService.updateAvatar(driverId, input);
const viewModel = presenter.viewModel;
res.status(HttpStatus.OK).json(viewModel);
}
}

View File

@@ -1,24 +1,12 @@
import { Injectable, Inject } from '@nestjs/common';
import type { RequestAvatarGenerationInputDTO } from './dtos/RequestAvatarGenerationInputDTO';
import type { RequestAvatarGenerationOutputDTO } from './dtos/RequestAvatarGenerationOutputDTO';
import type { UploadMediaInputDTO } from './dtos/UploadMediaInputDTO';
import type { UploadMediaOutputDTO } from './dtos/UploadMediaOutputDTO';
import type { GetMediaOutputDTO } from './dtos/GetMediaOutputDTO';
import type { DeleteMediaOutputDTO } from './dtos/DeleteMediaOutputDTO';
import type { GetAvatarOutputDTO } from './dtos/GetAvatarOutputDTO';
import type { UpdateAvatarInputDTO } from './dtos/UpdateAvatarInputDTO';
import type { UpdateAvatarOutputDTO } from './dtos/UpdateAvatarOutputDTO';
import type { RacingSuitColor } from '@core/media/domain/types/AvatarGenerationRequest';
type RequestAvatarGenerationInput = RequestAvatarGenerationInputDTO;
type RequestAvatarGenerationOutput = RequestAvatarGenerationOutputDTO;
type UploadMediaInput = UploadMediaInputDTO;
type UploadMediaOutput = UploadMediaOutputDTO;
type GetMediaOutput = GetMediaOutputDTO;
type DeleteMediaOutput = DeleteMediaOutputDTO;
type GetAvatarOutput = GetAvatarOutputDTO;
type UpdateAvatarInput = UpdateAvatarInputDTO;
type UpdateAvatarOutput = UpdateAvatarOutputDTO;
// Use cases
import { RequestAvatarGenerationUseCase } from '@core/media/application/use-cases/RequestAvatarGenerationUseCase';
@@ -60,7 +48,7 @@ export class MediaService {
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
) {}
async requestAvatarGeneration(input: RequestAvatarGenerationInput): Promise<RequestAvatarGenerationOutput> {
async requestAvatarGeneration(input: RequestAvatarGenerationInput): Promise<RequestAvatarGenerationPresenter> {
this.logger.debug('[MediaService] Requesting avatar generation.');
const presenter = new RequestAvatarGenerationPresenter();
@@ -69,10 +57,11 @@ export class MediaService {
facePhotoData: input.facePhotoData,
suitColor: input.suitColor as RacingSuitColor,
}, presenter);
return presenter.viewModel;
return presenter;
}
async uploadMedia(input: UploadMediaInput & { file: Express.Multer.File }): Promise<UploadMediaOutput> {
async uploadMedia(input: UploadMediaInput & { file: Express.Multer.File }): Promise<UploadMediaPresenter> {
this.logger.debug('[MediaService] Uploading media.');
const presenter = new UploadMediaPresenter();
@@ -83,102 +72,49 @@ export class MediaService {
metadata: input.metadata,
}, presenter);
const result = presenter.viewModel;
if (result.success) {
return {
success: true,
mediaId: result.mediaId!,
url: result.url!,
};
} else {
return {
success: false,
errorMessage: result.errorMessage || 'Upload failed',
};
}
return presenter;
}
async getMedia(mediaId: string): Promise<GetMediaOutput | null> {
async getMedia(mediaId: string): Promise<GetMediaPresenter> {
this.logger.debug(`[MediaService] Getting media: ${mediaId}`);
const presenter = new GetMediaPresenter();
await this.getMediaUseCase.execute({ mediaId }, presenter);
const result = presenter.viewModel;
if (result.success && result.media) {
return {
success: true,
mediaId: result.media.id,
filename: result.media.filename,
originalName: result.media.originalName,
mimeType: result.media.mimeType,
size: result.media.size,
url: result.media.url,
type: result.media.type,
uploadedBy: result.media.uploadedBy,
uploadedAt: result.media.uploadedAt,
metadata: result.media.metadata,
};
}
return null;
return presenter;
}
async deleteMedia(mediaId: string): Promise<DeleteMediaOutput> {
async deleteMedia(mediaId: string): Promise<DeleteMediaPresenter> {
this.logger.debug(`[MediaService] Deleting media: ${mediaId}`);
const presenter = new DeleteMediaPresenter();
await this.deleteMediaUseCase.execute({ mediaId }, presenter);
const result = presenter.viewModel;
return {
success: result.success,
errorMessage: result.errorMessage,
};
return presenter;
}
async getAvatar(driverId: string): Promise<GetAvatarOutput | null> {
async getAvatar(driverId: string): Promise<GetAvatarPresenter> {
this.logger.debug(`[MediaService] Getting avatar for driver: ${driverId}`);
const presenter = new GetAvatarPresenter();
await this.getAvatarUseCase.execute({ driverId }, presenter);
const result = presenter.viewModel;
if (result.success && result.avatar) {
return {
success: true,
avatarId: result.avatar.id,
driverId: result.avatar.driverId,
mediaUrl: result.avatar.mediaUrl,
selectedAt: result.avatar.selectedAt,
};
}
return null;
return presenter;
}
async updateAvatar(driverId: string, input: UpdateAvatarInput): Promise<UpdateAvatarOutput> {
async updateAvatar(driverId: string, input: UpdateAvatarInput): Promise<UpdateAvatarPresenter> {
this.logger.debug(`[MediaService] Updating avatar for driver: ${driverId}`);
const presenter = new UpdateAvatarPresenter();
await this.updateAvatarUseCase.execute({
driverId,
mediaUrl: input.mediaUrl,
}, presenter);
const result = presenter.viewModel;
return {
success: result.success,
errorMessage: result.errorMessage,
};
return presenter;
}
}

View File

@@ -1,4 +1,7 @@
import type { IDeleteMediaPresenter, DeleteMediaResult } from '@core/media/application/presenters/IDeleteMediaPresenter';
import type { DeleteMediaOutputDTO } from '../dtos/DeleteMediaOutputDTO';
type DeleteMediaOutput = DeleteMediaOutputDTO;
export class DeleteMediaPresenter implements IDeleteMediaPresenter {
private result: DeleteMediaResult | null = null;
@@ -7,8 +10,12 @@ export class DeleteMediaPresenter implements IDeleteMediaPresenter {
this.result = result;
}
get viewModel(): DeleteMediaResult {
get viewModel(): DeleteMediaOutput {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
return {
success: this.result.success,
error: this.result.errorMessage,
};
}
}

View File

@@ -1,4 +1,7 @@
import type { IGetAvatarPresenter, GetAvatarResult } from '@core/media/application/presenters/IGetAvatarPresenter';
import type { GetAvatarOutputDTO } from '../dtos/GetAvatarOutputDTO';
export type GetAvatarViewModel = GetAvatarOutputDTO | null;
export class GetAvatarPresenter implements IGetAvatarPresenter {
private result: GetAvatarResult | null = null;
@@ -7,8 +10,13 @@ export class GetAvatarPresenter implements IGetAvatarPresenter {
this.result = result;
}
get viewModel(): GetAvatarResult {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
get viewModel(): GetAvatarViewModel {
if (!this.result || !this.result.success || !this.result.avatar) {
return null;
}
return {
avatarUrl: this.result.avatar.mediaUrl,
};
}
}

View File

@@ -1,4 +1,8 @@
import type { IGetMediaPresenter, GetMediaResult } from '@core/media/application/presenters/IGetMediaPresenter';
import type { GetMediaOutputDTO } from '../dtos/GetMediaOutputDTO';
// The HTTP-facing DTO (or null when not found)
export type GetMediaViewModel = GetMediaOutputDTO | null;
export class GetMediaPresenter implements IGetMediaPresenter {
private result: GetMediaResult | null = null;
@@ -7,8 +11,21 @@ export class GetMediaPresenter implements IGetMediaPresenter {
this.result = result;
}
get viewModel(): GetMediaResult {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
get viewModel(): GetMediaViewModel {
if (!this.result || !this.result.success || !this.result.media) {
return null;
}
const media = this.result.media;
return {
id: media.id,
url: media.url,
type: media.type,
// Best-effort mapping from arbitrary metadata
category: (media.metadata as { category?: string } | undefined)?.category,
uploadedAt: media.uploadedAt,
size: media.size,
};
}
}

View File

@@ -1,14 +1,21 @@
import type { IUpdateAvatarPresenter, UpdateAvatarResult } from '@core/media/application/presenters/IUpdateAvatarPresenter';
import type { UpdateAvatarOutputDTO } from '../dtos/UpdateAvatarOutputDTO';
type UpdateAvatarOutput = UpdateAvatarOutputDTO;
export class UpdateAvatarPresenter implements IUpdateAvatarPresenter {
private result: UpdateAvatarResult | null = null;
present(result: UpdateAvatarResult) {
this.result = result;
}
get viewModel(): UpdateAvatarResult {
get viewModel(): UpdateAvatarOutput {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
return {
success: this.result.success,
error: this.result.errorMessage,
};
}
}

View File

@@ -1,4 +1,7 @@
import type { IUploadMediaPresenter, UploadMediaResult } from '@core/media/application/presenters/IUploadMediaPresenter';
import type { UploadMediaOutputDTO } from '../dtos/UploadMediaOutputDTO';
type UploadMediaOutput = UploadMediaOutputDTO;
export class UploadMediaPresenter implements IUploadMediaPresenter {
private result: UploadMediaResult | null = null;
@@ -7,8 +10,20 @@ export class UploadMediaPresenter implements IUploadMediaPresenter {
this.result = result;
}
get viewModel(): UploadMediaResult {
get viewModel(): UploadMediaOutput {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
if (this.result.success) {
return {
success: true,
mediaId: this.result.mediaId,
url: this.result.url,
};
}
return {
success: false,
error: this.result.errorMessage || 'Upload failed',
};
}
}

View File

@@ -40,7 +40,7 @@ describe('PaymentsController', () => {
it('should return payments', async () => {
const query: GetPaymentsQuery = { status: 'pending' };
const result = { payments: [] };
service.getPayments.mockResolvedValue(result);
service.getPayments.mockResolvedValue({ viewModel: result } as any);
const response = await controller.getPayments(query);
@@ -53,7 +53,7 @@ describe('PaymentsController', () => {
it('should create payment', async () => {
const input: CreatePaymentInput = { amount: 100, type: 'membership_fee', payerId: 'payer-123', payerType: 'driver', leagueId: 'league-123' };
const result = { payment: { id: 'pay-123' } };
service.createPayment.mockResolvedValue(result);
service.createPayment.mockResolvedValue({ viewModel: result } as any);
const response = await controller.createPayment(input);
@@ -66,7 +66,7 @@ describe('PaymentsController', () => {
it('should update payment status', async () => {
const input: UpdatePaymentStatusInput = { paymentId: 'pay-123', status: 'completed' };
const result = { payment: { id: 'pay-123', status: 'completed' } };
service.updatePaymentStatus.mockResolvedValue(result);
service.updatePaymentStatus.mockResolvedValue({ viewModel: result } as any);
const response = await controller.updatePaymentStatus(input);
@@ -79,7 +79,7 @@ describe('PaymentsController', () => {
it('should return membership fees', async () => {
const query: GetMembershipFeesQuery = { leagueId: 'league-123' };
const result = { fees: [] };
service.getMembershipFees.mockResolvedValue(result);
service.getMembershipFees.mockResolvedValue({ viewModel: result } as any);
const response = await controller.getMembershipFees(query);
@@ -92,7 +92,7 @@ describe('PaymentsController', () => {
it('should upsert membership fee', async () => {
const input: UpsertMembershipFeeInput = { leagueId: 'league-123', amount: 50 };
const result = { feeId: 'fee-123' };
service.upsertMembershipFee.mockResolvedValue(result);
service.upsertMembershipFee.mockResolvedValue({ viewModel: result } as any);
const response = await controller.upsertMembershipFee(input);
@@ -105,7 +105,7 @@ describe('PaymentsController', () => {
it('should update member payment', async () => {
const input: UpdateMemberPaymentInput = { memberId: 'member-123', paymentId: 'pay-123' };
const result = { success: true };
service.updateMemberPayment.mockResolvedValue(result);
service.updateMemberPayment.mockResolvedValue({ viewModel: result } as any);
const response = await controller.updateMemberPayment(input);
@@ -118,7 +118,7 @@ describe('PaymentsController', () => {
it('should return prizes', async () => {
const query: GetPrizesQuery = { leagueId: 'league-123' };
const result = { prizes: [] };
service.getPrizes.mockResolvedValue(result);
service.getPrizes.mockResolvedValue({ viewModel: result } as any);
const response = await controller.getPrizes(query);
@@ -131,7 +131,7 @@ describe('PaymentsController', () => {
it('should create prize', async () => {
const input: CreatePrizeInput = { name: 'Prize', amount: 100 };
const result = { prizeId: 'prize-123' };
service.createPrize.mockResolvedValue(result);
service.createPrize.mockResolvedValue({ viewModel: result } as any);
const response = await controller.createPrize(input);
@@ -144,7 +144,7 @@ describe('PaymentsController', () => {
it('should award prize', async () => {
const input: AwardPrizeInput = { prizeId: 'prize-123', driverId: 'driver-123' };
const result = { success: true };
service.awardPrize.mockResolvedValue(result);
service.awardPrize.mockResolvedValue({ viewModel: result } as any);
const response = await controller.awardPrize(input);
@@ -157,7 +157,7 @@ describe('PaymentsController', () => {
it('should delete prize', async () => {
const query: DeletePrizeInput = { prizeId: 'prize-123' };
const result = { success: true };
service.deletePrize.mockResolvedValue(result);
service.deletePrize.mockResolvedValue({ viewModel: result } as any);
const response = await controller.deletePrize(query);
@@ -170,7 +170,7 @@ describe('PaymentsController', () => {
it('should return wallet', async () => {
const query: GetWalletQuery = { userId: 'user-123' };
const result = { balance: 100 };
service.getWallet.mockResolvedValue(result);
service.getWallet.mockResolvedValue({ viewModel: result } as any);
const response = await controller.getWallet(query);
@@ -183,7 +183,7 @@ describe('PaymentsController', () => {
it('should process wallet transaction', async () => {
const input: ProcessWalletTransactionInput = { userId: 'user-123', amount: 50, type: 'deposit' };
const result = { transactionId: 'tx-123' };
service.processWalletTransaction.mockResolvedValue(result);
service.processWalletTransaction.mockResolvedValue({ viewModel: result } as any);
const response = await controller.processWalletTransaction(input);
@@ -191,4 +191,4 @@ describe('PaymentsController', () => {
expect(response).toEqual(result);
});
});
});
});

View File

@@ -12,7 +12,8 @@ export class PaymentsController {
@ApiOperation({ summary: 'Get payments based on filters' })
@ApiResponse({ status: 200, description: 'List of payments', type: GetPaymentsOutput })
async getPayments(@Query() query: GetPaymentsQuery): Promise<GetPaymentsOutput> {
return this.paymentsService.getPayments(query);
const presenter = await this.paymentsService.getPayments(query);
return presenter.viewModel;
}
@Post()
@@ -20,21 +21,24 @@ export class PaymentsController {
@ApiOperation({ summary: 'Create a new payment' })
@ApiResponse({ status: 201, description: 'Payment created', type: CreatePaymentOutput })
async createPayment(@Body() input: CreatePaymentInput): Promise<CreatePaymentOutput> {
return this.paymentsService.createPayment(input);
const presenter = await this.paymentsService.createPayment(input);
return presenter.viewModel;
}
@Patch('status')
@ApiOperation({ summary: 'Update the status of a payment' })
@ApiResponse({ status: 200, description: 'Payment status updated', type: UpdatePaymentStatusOutput })
async updatePaymentStatus(@Body() input: UpdatePaymentStatusInput): Promise<UpdatePaymentStatusOutput> {
return this.paymentsService.updatePaymentStatus(input);
const presenter = await this.paymentsService.updatePaymentStatus(input);
return presenter.viewModel;
}
@Get('membership-fees')
@ApiOperation({ summary: 'Get membership fees and member payments' })
@ApiResponse({ status: 200, description: 'Membership fee configuration and member payments', type: GetMembershipFeesOutput })
async getMembershipFees(@Query() query: GetMembershipFeesQuery): Promise<GetMembershipFeesOutput> {
return this.paymentsService.getMembershipFees(query);
const presenter = await this.paymentsService.getMembershipFees(query);
return presenter.viewModel;
}
@Post('membership-fees')
@@ -42,20 +46,23 @@ export class PaymentsController {
@ApiOperation({ summary: 'Create or update membership fee configuration' })
@ApiResponse({ status: 201, description: 'Membership fee configuration created or updated', type: UpsertMembershipFeeOutput })
async upsertMembershipFee(@Body() input: UpsertMembershipFeeInput): Promise<UpsertMembershipFeeOutput> {
return this.paymentsService.upsertMembershipFee(input);
const presenter = await this.paymentsService.upsertMembershipFee(input);
return presenter.viewModel;
}
@Patch('membership-fees/member-payment')
@ApiOperation({ summary: 'Record or update a member payment' })
@ApiResponse({ status: 200, description: 'Member payment recorded or updated', type: UpdateMemberPaymentOutput })
async updateMemberPayment(@Body() input: UpdateMemberPaymentInput): Promise<UpdateMemberPaymentOutput> {
return this.paymentsService.updateMemberPayment(input);
const presenter = await this.paymentsService.updateMemberPayment(input);
return presenter.viewModel;
}
@Get('prizes')
@ApiOperation({ summary: 'Get prizes for a league or season' })
@ApiResponse({ status: 200, description: 'List of prizes', type: GetPrizesOutput })
async getPrizes(@Query() query: GetPrizesQuery): Promise<GetPrizesOutput> {
return this.paymentsService.getPrizes(query);
const presenter = await this.paymentsService.getPrizes(query);
return presenter.viewModel;
}
@Post('prizes')
@@ -63,27 +70,31 @@ export class PaymentsController {
@ApiOperation({ summary: 'Create a new prize' })
@ApiResponse({ status: 201, description: 'Prize created', type: CreatePrizeOutput })
async createPrize(@Body() input: CreatePrizeInput): Promise<CreatePrizeOutput> {
return this.paymentsService.createPrize(input);
const presenter = await this.paymentsService.createPrize(input);
return presenter.viewModel;
}
@Patch('prizes/award')
@ApiOperation({ summary: 'Award a prize to a driver' })
@ApiResponse({ status: 200, description: 'Prize awarded', type: AwardPrizeOutput })
async awardPrize(@Body() input: AwardPrizeInput): Promise<AwardPrizeOutput> {
return this.paymentsService.awardPrize(input);
const presenter = await this.paymentsService.awardPrize(input);
return presenter.viewModel;
}
@Delete('prizes')
@ApiOperation({ summary: 'Delete a prize' })
@ApiResponse({ status: 200, description: 'Prize deleted', type: DeletePrizeOutput })
async deletePrize(@Query() query: DeletePrizeInput): Promise<DeletePrizeOutput> {
return this.paymentsService.deletePrize(query);
const presenter = await this.paymentsService.deletePrize(query);
return presenter.viewModel;
}
@Get('wallets')
@ApiOperation({ summary: 'Get wallet information and transactions' })
@ApiResponse({ status: 200, description: 'Wallet and transaction data', type: GetWalletOutput })
async getWallet(@Query() query: GetWalletQuery): Promise<GetWalletOutput> {
return this.paymentsService.getWallet(query);
const presenter = await this.paymentsService.getWallet(query);
return presenter.viewModel;
}
@Post('wallets/transactions')
@@ -91,6 +102,7 @@ export class PaymentsController {
@ApiOperation({ summary: 'Process a wallet transaction (deposit or withdrawal)' })
@ApiResponse({ status: 201, description: 'Wallet transaction processed', type: ProcessWalletTransactionOutput })
async processWalletTransaction(@Body() input: ProcessWalletTransactionInput): Promise<ProcessWalletTransactionOutput> {
return this.paymentsService.processWalletTransaction(input);
const presenter = await this.paymentsService.processWalletTransaction(input);
return presenter.viewModel;
}
}

View File

@@ -92,99 +92,99 @@ export class PaymentsService {
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
) {}
async getPayments(query: GetPaymentsQuery): Promise<GetPaymentsOutput> {
async getPayments(query: GetPaymentsQuery): Promise<GetPaymentsPresenter> {
this.logger.debug('[PaymentsService] Getting payments', { query });
const presenter = new GetPaymentsPresenter();
await this.getPaymentsUseCase.execute(query, presenter);
return presenter.viewModel;
return presenter;
}
async createPayment(input: CreatePaymentInput): Promise<CreatePaymentOutput> {
async createPayment(input: CreatePaymentInput): Promise<CreatePaymentPresenter> {
this.logger.debug('[PaymentsService] Creating payment', { input });
const presenter = new CreatePaymentPresenter();
await this.createPaymentUseCase.execute(input, presenter);
return presenter.viewModel;
return presenter;
}
async updatePaymentStatus(input: UpdatePaymentStatusInput): Promise<UpdatePaymentStatusOutput> {
async updatePaymentStatus(input: UpdatePaymentStatusInput): Promise<UpdatePaymentStatusPresenter> {
this.logger.debug('[PaymentsService] Updating payment status', { input });
const presenter = new UpdatePaymentStatusPresenter();
await this.updatePaymentStatusUseCase.execute(input, presenter);
return presenter.viewModel;
return presenter;
}
async getMembershipFees(query: GetMembershipFeesQuery): Promise<GetMembershipFeesOutput> {
async getMembershipFees(query: GetMembershipFeesQuery): Promise<GetMembershipFeesPresenter> {
this.logger.debug('[PaymentsService] Getting membership fees', { query });
const presenter = new GetMembershipFeesPresenter();
await this.getMembershipFeesUseCase.execute(query, presenter);
return presenter.viewModel;
return presenter;
}
async upsertMembershipFee(input: UpsertMembershipFeeInput): Promise<UpsertMembershipFeeOutput> {
async upsertMembershipFee(input: UpsertMembershipFeeInput): Promise<UpsertMembershipFeePresenter> {
this.logger.debug('[PaymentsService] Upserting membership fee', { input });
const presenter = new UpsertMembershipFeePresenter();
await this.upsertMembershipFeeUseCase.execute(input, presenter);
return presenter.viewModel;
return presenter;
}
async updateMemberPayment(input: UpdateMemberPaymentInput): Promise<UpdateMemberPaymentOutput> {
async updateMemberPayment(input: UpdateMemberPaymentInput): Promise<UpdateMemberPaymentPresenter> {
this.logger.debug('[PaymentsService] Updating member payment', { input });
const presenter = new UpdateMemberPaymentPresenter();
await this.updateMemberPaymentUseCase.execute(input, presenter);
return presenter.viewModel;
return presenter;
}
async getPrizes(query: GetPrizesQuery): Promise<GetPrizesOutput> {
async getPrizes(query: GetPrizesQuery): Promise<GetPrizesPresenter> {
this.logger.debug('[PaymentsService] Getting prizes', { query });
const presenter = new GetPrizesPresenter();
await this.getPrizesUseCase.execute({ leagueId: query.leagueId!, seasonId: query.seasonId }, presenter);
return presenter.viewModel;
return presenter;
}
async createPrize(input: CreatePrizeInput): Promise<CreatePrizeOutput> {
async createPrize(input: CreatePrizeInput): Promise<CreatePrizePresenter> {
this.logger.debug('[PaymentsService] Creating prize', { input });
const presenter = new CreatePrizePresenter();
await this.createPrizeUseCase.execute(input, presenter);
return presenter.viewModel;
return presenter;
}
async awardPrize(input: AwardPrizeInput): Promise<AwardPrizeOutput> {
async awardPrize(input: AwardPrizeInput): Promise<AwardPrizePresenter> {
this.logger.debug('[PaymentsService] Awarding prize', { input });
const presenter = new AwardPrizePresenter();
await this.awardPrizeUseCase.execute(input, presenter);
return presenter.viewModel;
return presenter;
}
async deletePrize(input: DeletePrizeInput): Promise<DeletePrizeOutput> {
async deletePrize(input: DeletePrizeInput): Promise<DeletePrizePresenter> {
this.logger.debug('[PaymentsService] Deleting prize', { input });
const presenter = new DeletePrizePresenter();
await this.deletePrizeUseCase.execute(input, presenter);
return presenter.viewModel;
return presenter;
}
async getWallet(query: GetWalletQuery): Promise<GetWalletOutput> {
async getWallet(query: GetWalletQuery): Promise<GetWalletPresenter> {
this.logger.debug('[PaymentsService] Getting wallet', { query });
const presenter = new GetWalletPresenter();
await this.getWalletUseCase.execute({ leagueId: query.leagueId! }, presenter);
return presenter.viewModel;
return presenter;
}
async processWalletTransaction(input: ProcessWalletTransactionInput): Promise<ProcessWalletTransactionOutput> {
async processWalletTransaction(input: ProcessWalletTransactionInput): Promise<ProcessWalletTransactionPresenter> {
this.logger.debug('[PaymentsService] Processing wallet transaction', { input });
const presenter = new ProcessWalletTransactionPresenter();
await this.processWalletTransactionUseCase.execute(input, presenter);
return presenter.viewModel;
return presenter;
}
}

View File

@@ -1,19 +1,21 @@
import { Test, TestingModule } from '@nestjs/testing';
import { vi } from 'vitest';
import { vi, type MockedFunction } from 'vitest';
import { ForbiddenException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { ProtestsController } from './ProtestsController';
import { RaceService } from '../race/RaceService';
import { ProtestsService } from './ProtestsService';
import { ReviewProtestCommandDTO } from '../race/dtos/ReviewProtestCommandDTO';
import type { ReviewProtestPresenter } from './presenters/ReviewProtestPresenter';
describe('ProtestsController', () => {
let controller: ProtestsController;
let raceService: ReturnType<typeof vi.mocked<RaceService>>;
let reviewProtestMock: MockedFunction<ProtestsService['reviewProtest']>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ProtestsController],
providers: [
{
provide: RaceService,
provide: ProtestsService,
useValue: {
reviewProtest: vi.fn(),
},
@@ -22,18 +24,98 @@ describe('ProtestsController', () => {
}).compile();
controller = module.get<ProtestsController>(ProtestsController);
raceService = vi.mocked(module.get(RaceService));
const service = module.get(ProtestsService);
reviewProtestMock = vi.mocked(service.reviewProtest);
});
const successPresenter = (viewModel: ReviewProtestPresenter['viewModel']): ReviewProtestPresenter => ({
get viewModel() {
return viewModel;
},
getViewModel: () => viewModel,
reset: vi.fn(),
presentSuccess: vi.fn(),
presentError: vi.fn(),
} as unknown as ReviewProtestPresenter);
describe('reviewProtest', () => {
it('should review protest', async () => {
it('should call service and not throw on success', async () => {
const protestId = 'protest-123';
const body: Omit<ReviewProtestCommandDTO, 'protestId'> = { decision: 'upheld', reason: 'Reason' };
raceService.reviewProtest.mockResolvedValue(undefined);
const body: Omit<ReviewProtestCommandDTO, 'protestId'> = {
stewardId: 'steward-1',
decision: 'uphold',
decisionNotes: 'Reason',
};
reviewProtestMock.mockResolvedValue(
successPresenter({
success: true,
protestId,
stewardId: body.stewardId,
decision: body.decision,
}),
);
await controller.reviewProtest(protestId, body);
expect(raceService.reviewProtest).toHaveBeenCalledWith({ protestId, ...body });
expect(reviewProtestMock).toHaveBeenCalledWith({ protestId, ...body });
});
it('should throw NotFoundException when protest is not found', async () => {
const protestId = 'protest-123';
const body: Omit<ReviewProtestCommandDTO, 'protestId'> = {
stewardId: 'steward-1',
decision: 'uphold',
decisionNotes: 'Reason',
};
reviewProtestMock.mockResolvedValue(
successPresenter({
success: false,
errorCode: 'PROTEST_NOT_FOUND',
message: 'Protest not found',
}),
);
await expect(controller.reviewProtest(protestId, body)).rejects.toBeInstanceOf(NotFoundException);
});
it('should throw ForbiddenException when steward is not league admin', async () => {
const protestId = 'protest-123';
const body: Omit<ReviewProtestCommandDTO, 'protestId'> = {
stewardId: 'steward-1',
decision: 'uphold',
decisionNotes: 'Reason',
};
reviewProtestMock.mockResolvedValue(
successPresenter({
success: false,
errorCode: 'NOT_LEAGUE_ADMIN',
message: 'Not authorized',
}),
);
await expect(controller.reviewProtest(protestId, body)).rejects.toBeInstanceOf(ForbiddenException);
});
it('should throw InternalServerErrorException for unexpected error codes', async () => {
const protestId = 'protest-123';
const body: Omit<ReviewProtestCommandDTO, 'protestId'> = {
stewardId: 'steward-1',
decision: 'uphold',
decisionNotes: 'Reason',
};
reviewProtestMock.mockResolvedValue(
successPresenter({
success: false,
errorCode: 'UNEXPECTED_ERROR',
message: 'Unexpected',
}),
);
await expect(controller.reviewProtest(protestId, body)).rejects.toBeInstanceOf(InternalServerErrorException);
});
});
});
});

View File

@@ -1,12 +1,12 @@
import { Controller, Post, Body, HttpCode, HttpStatus, Param } from '@nestjs/common';
import { ApiTags, ApiResponse, ApiOperation, ApiParam } from '@nestjs/swagger';
import { RaceService } from '../race/RaceService';
import { Body, Controller, ForbiddenException, HttpCode, HttpStatus, InternalServerErrorException, NotFoundException, Param, Post } from '@nestjs/common';
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ProtestsService } from './ProtestsService';
import { ReviewProtestCommandDTO } from '../race/dtos/ReviewProtestCommandDTO';
@ApiTags('protests')
@Controller('protests')
export class ProtestsController {
constructor(private readonly raceService: RaceService) {}
constructor(private readonly protestsService: ProtestsService) {}
@Post(':protestId/review')
@HttpCode(HttpStatus.OK)
@@ -17,6 +17,20 @@ export class ProtestsController {
@Param('protestId') protestId: string,
@Body() body: Omit<ReviewProtestCommandDTO, 'protestId'>,
): Promise<void> {
return this.raceService.reviewProtest({ protestId, ...body });
const presenter = await this.protestsService.reviewProtest({ protestId, ...body });
const viewModel = presenter.viewModel;
if (!viewModel.success) {
switch (viewModel.errorCode) {
case 'PROTEST_NOT_FOUND':
throw new NotFoundException(viewModel.message ?? 'Protest not found');
case 'RACE_NOT_FOUND':
throw new NotFoundException(viewModel.message ?? 'Race not found for protest');
case 'NOT_LEAGUE_ADMIN':
throw new ForbiddenException(viewModel.message ?? 'Steward is not authorized to review this protest');
default:
throw new InternalServerErrorException(viewModel.message ?? 'Failed to review protest');
}
}
}
}
}

View File

@@ -0,0 +1,101 @@
import { describe, it, expect, beforeEach, vi, type MockedFunction } from 'vitest';
import { Result } from '@core/shared/application/Result';
import type { Logger } from '@core/shared/application/Logger';
import type { ReviewProtestUseCase } from '@core/racing/application/use-cases/ReviewProtestUseCase';
import { ProtestsService } from './ProtestsService';
import type { ReviewProtestPresenter } from './presenters/ReviewProtestPresenter';
describe('ProtestsService', () => {
let service: ProtestsService;
let executeMock: MockedFunction<ReviewProtestUseCase['execute']>;
let logger: Logger;
beforeEach(() => {
executeMock = vi.fn();
const reviewProtestUseCase = { execute: executeMock } as unknown as ReviewProtestUseCase;
logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger;
service = new ProtestsService(reviewProtestUseCase, logger);
});
const baseCommand = {
protestId: 'protest-1',
stewardId: 'steward-1',
decision: 'uphold' as const,
decisionNotes: 'Notes',
};
const getViewModel = (presenter: ReviewProtestPresenter) => presenter.viewModel;
it('returns presenter with success view model on success', async () => {
executeMock.mockResolvedValue(Result.ok<void, never>(undefined));
const presenter = await service.reviewProtest(baseCommand);
const viewModel = getViewModel(presenter as ReviewProtestPresenter);
expect(executeMock).toHaveBeenCalledWith(baseCommand);
expect(viewModel).toEqual({
success: true,
protestId: baseCommand.protestId,
stewardId: baseCommand.stewardId,
decision: baseCommand.decision,
});
});
it('maps PROTEST_NOT_FOUND error into presenter', async () => {
executeMock.mockResolvedValue(Result.err({ code: 'PROTEST_NOT_FOUND' as const }));
const presenter = await service.reviewProtest(baseCommand);
const viewModel = getViewModel(presenter as ReviewProtestPresenter);
expect(viewModel).toEqual({
success: false,
errorCode: 'PROTEST_NOT_FOUND',
message: 'Protest not found',
});
});
it('maps RACE_NOT_FOUND error into presenter', async () => {
executeMock.mockResolvedValue(Result.err({ code: 'RACE_NOT_FOUND' as const }));
const presenter = await service.reviewProtest(baseCommand);
const viewModel = getViewModel(presenter as ReviewProtestPresenter);
expect(viewModel).toEqual({
success: false,
errorCode: 'RACE_NOT_FOUND',
message: 'Race not found for protest',
});
});
it('maps NOT_LEAGUE_ADMIN error into presenter', async () => {
executeMock.mockResolvedValue(Result.err({ code: 'NOT_LEAGUE_ADMIN' as const }));
const presenter = await service.reviewProtest(baseCommand);
const viewModel = getViewModel(presenter as ReviewProtestPresenter);
expect(viewModel).toEqual({
success: false,
errorCode: 'NOT_LEAGUE_ADMIN',
message: 'Steward is not authorized to review this protest',
});
});
it('maps unexpected error code into generic failure', async () => {
executeMock.mockResolvedValue(Result.err({ code: 'UNEXPECTED' as unknown as never }));
const presenter = await service.reviewProtest(baseCommand);
const viewModel = getViewModel(presenter as ReviewProtestPresenter);
expect(viewModel).toEqual({
success: false,
errorCode: 'UNEXPECTED',
message: 'Failed to review protest',
});
});
});

View File

@@ -1,9 +1,12 @@
import { Injectable, Inject } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import type { Logger } from '@core/shared/application/Logger';
// Use cases
import { ReviewProtestUseCase } from '@core/racing/application/use-cases/ReviewProtestUseCase';
// Presenter
import { ReviewProtestPresenter } from './presenters/ReviewProtestPresenter';
// Tokens
import { LOGGER_TOKEN } from './ProtestsProviders';
@@ -19,13 +22,41 @@ export class ProtestsService {
stewardId: string;
decision: 'uphold' | 'dismiss';
decisionNotes: string;
}): Promise<void> {
}): Promise<ReviewProtestPresenter> {
this.logger.debug('[ProtestsService] Reviewing protest:', command);
const presenter = new ReviewProtestPresenter();
const result = await this.reviewProtestUseCase.execute(command);
if (result.isErr()) {
throw new Error(result.error.details.message || 'Failed to review protest');
const error = result.unwrapErr();
let message: string;
switch (error.code) {
case 'PROTEST_NOT_FOUND':
message = 'Protest not found';
break;
case 'RACE_NOT_FOUND':
message = 'Race not found for protest';
break;
case 'NOT_LEAGUE_ADMIN':
message = 'Steward is not authorized to review this protest';
break;
default:
message = 'Failed to review protest';
break;
}
presenter.presentError(error.code, message);
return presenter;
}
presenter.presentSuccess({
protestId: command.protestId,
stewardId: command.stewardId,
decision: command.decision,
});
return presenter;
}
}
}

View File

@@ -0,0 +1,45 @@
export interface ReviewProtestViewModel {
success: boolean;
errorCode?: string;
message?: string;
protestId?: string;
stewardId?: string;
decision?: 'uphold' | 'dismiss';
}
export class ReviewProtestPresenter {
private result: ReviewProtestViewModel | null = null;
reset(): void {
this.result = null;
}
presentSuccess(payload: { protestId: string; stewardId: string; decision: 'uphold' | 'dismiss' }): void {
this.result = {
success: true,
protestId: payload.protestId,
stewardId: payload.stewardId,
decision: payload.decision,
};
}
presentError(errorCode: string, message?: string): void {
this.result = {
success: false,
errorCode,
message,
};
}
getViewModel(): ReviewProtestViewModel | null {
return this.result;
}
get viewModel(): ReviewProtestViewModel {
if (!this.result) {
throw new Error('Presenter not presented');
}
return this.result;
}
}

View File

@@ -26,7 +26,7 @@ describe('RaceController', () => {
applyQuickPenalty: jest.fn(),
applyPenalty: jest.fn(),
requestProtestDefense: jest.fn(),
};
} as unknown as jest.Mocked<RaceService>;
const module: TestingModule = await Test.createTestingModule({
controllers: [RaceController],
@@ -39,7 +39,7 @@ describe('RaceController', () => {
}).compile();
controller = module.get<RaceController>(RaceController);
service = module.get(RaceService);
service = module.get(RaceService) as jest.Mocked<RaceService>;
});
it('should be defined', () => {
@@ -47,28 +47,26 @@ describe('RaceController', () => {
});
describe('getAllRaces', () => {
it('should return all races', async () => {
const mockResult = { races: [], totalCount: 0 };
service.getAllRaces.mockResolvedValue(mockResult);
it('should return all races view model', async () => {
const mockViewModel = { races: [], filters: { statuses: [], leagues: [] } } as { races: unknown[]; filters: { statuses: unknown[]; leagues: unknown[] } };
service.getAllRaces.mockResolvedValue({ viewModel: mockViewModel } as unknown as ReturnType<RaceService['getAllRaces']>);
const result = await controller.getAllRaces();
expect(service.getAllRaces).toHaveBeenCalled();
expect(result).toEqual(mockResult);
expect(result).toEqual(mockViewModel);
});
});
describe('getTotalRaces', () => {
it('should return total races count', async () => {
const mockResult = { totalRaces: 5 };
service.getTotalRaces.mockResolvedValue(mockResult);
it('should return total races count view model', async () => {
const mockViewModel = { totalRaces: 5 } as { totalRaces: number };
service.getTotalRaces.mockResolvedValue({ viewModel: mockViewModel } as unknown as ReturnType<RaceService['getTotalRaces']>);
const result = await controller.getTotalRaces();
expect(service.getTotalRaces).toHaveBeenCalled();
expect(result).toEqual(mockResult);
expect(result).toEqual(mockViewModel);
});
});
// Add more tests as needed
});

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Query } from '@nestjs/common';
import { Body, Controller, Get, HttpCode, HttpStatus, InternalServerErrorException, Param, Post, Query } from '@nestjs/common';
import { ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { RaceService } from './RaceService';
import { AllRacesPageDTO } from './dtos/AllRacesPageDTO';
@@ -27,28 +27,32 @@ export class RaceController {
@ApiOperation({ summary: 'Get all races' })
@ApiResponse({ status: 200, description: 'List of all races', type: AllRacesPageDTO })
async getAllRaces(): Promise<AllRacesPageDTO> {
return this.raceService.getAllRaces();
const presenter = await this.raceService.getAllRaces();
return presenter.viewModel;
}
@Get('total-races')
@ApiOperation({ summary: 'Get the total number of races' })
@ApiResponse({ status: 200, description: 'Total number of races', type: RaceStatsDTO })
async getTotalRaces(): Promise<RaceStatsDTO> {
return this.raceService.getTotalRaces();
const presenter = await this.raceService.getTotalRaces();
return presenter.viewModel;
}
@Get('page-data')
@ApiOperation({ summary: 'Get races page data' })
@ApiResponse({ status: 200, description: 'Races page data', type: RacesPageDataDTO })
async getRacesPageData(): Promise<RacesPageDataDTO> {
return this.raceService.getRacesPageData();
const presenter = await this.raceService.getRacesPageData();
return presenter.viewModel;
}
@Get('all/page-data')
@ApiOperation({ summary: 'Get all races page data' })
@ApiResponse({ status: 200, description: 'All races page data', type: AllRacesPageDTO })
async getAllRacesPageData(): Promise<AllRacesPageDTO> {
return this.raceService.getAllRacesPageData();
const presenter = await this.raceService.getAllRacesPageData();
return presenter.viewModel;
}
@Get(':raceId')
@@ -60,7 +64,8 @@ export class RaceController {
@Param('raceId') raceId: string,
@Query('driverId') driverId: string,
): Promise<RaceDetailDTO> {
return this.raceService.getRaceDetail({ raceId, driverId });
const presenter = await this.raceService.getRaceDetail({ raceId, driverId });
return presenter.viewModel;
}
@Get(':raceId/results')
@@ -68,7 +73,8 @@ export class RaceController {
@ApiParam({ name: 'raceId', description: 'Race ID' })
@ApiResponse({ status: 200, description: 'Race results detail', type: RaceResultsDetailDTO })
async getRaceResultsDetail(@Param('raceId') raceId: string): Promise<RaceResultsDetailDTO> {
return this.raceService.getRaceResultsDetail(raceId);
const presenter = await this.raceService.getRaceResultsDetail(raceId);
return presenter.viewModel;
}
@Get(':raceId/sof')
@@ -76,7 +82,8 @@ export class RaceController {
@ApiParam({ name: 'raceId', description: 'Race ID' })
@ApiResponse({ status: 200, description: 'Race with SOF', type: RaceWithSOFDTO })
async getRaceWithSOF(@Param('raceId') raceId: string): Promise<RaceWithSOFDTO> {
return this.raceService.getRaceWithSOF(raceId);
const presenter = await this.raceService.getRaceWithSOF(raceId);
return presenter.viewModel;
}
@Get(':raceId/protests')
@@ -84,7 +91,8 @@ export class RaceController {
@ApiParam({ name: 'raceId', description: 'Race ID' })
@ApiResponse({ status: 200, description: 'Race protests', type: RaceProtestsDTO })
async getRaceProtests(@Param('raceId') raceId: string): Promise<RaceProtestsDTO> {
return this.raceService.getRaceProtests(raceId);
const presenter = await this.raceService.getRaceProtests(raceId);
return presenter.viewModel;
}
@Get(':raceId/penalties')
@@ -92,7 +100,8 @@ export class RaceController {
@ApiParam({ name: 'raceId', description: 'Race ID' })
@ApiResponse({ status: 200, description: 'Race penalties', type: RacePenaltiesDTO })
async getRacePenalties(@Param('raceId') raceId: string): Promise<RacePenaltiesDTO> {
return this.raceService.getRacePenalties(raceId);
const presenter = await this.raceService.getRacePenalties(raceId);
return presenter.viewModel;
}
@Post(':raceId/register')
@@ -104,7 +113,12 @@ export class RaceController {
@Param('raceId') raceId: string,
@Body() body: Omit<RegisterForRaceParamsDTO, 'raceId'>,
): Promise<void> {
return this.raceService.registerForRace({ raceId, ...body });
const presenter = await this.raceService.registerForRace({ raceId, ...body });
const viewModel = presenter.viewModel;
if (!viewModel.success) {
throw new InternalServerErrorException(viewModel.message ?? 'Failed to register for race');
}
}
@Post(':raceId/withdraw')
@@ -116,7 +130,12 @@ export class RaceController {
@Param('raceId') raceId: string,
@Body() body: Omit<WithdrawFromRaceParamsDTO, 'raceId'>,
): Promise<void> {
return this.raceService.withdrawFromRace({ raceId, ...body });
const presenter = await this.raceService.withdrawFromRace({ raceId, ...body });
const viewModel = presenter.viewModel;
if (!viewModel.success) {
throw new InternalServerErrorException(viewModel.message ?? 'Failed to withdraw from race');
}
}
@Post(':raceId/cancel')
@@ -125,7 +144,12 @@ export class RaceController {
@ApiParam({ name: 'raceId', description: 'Race ID' })
@ApiResponse({ status: 200, description: 'Successfully cancelled race' })
async cancelRace(@Param('raceId') raceId: string): Promise<void> {
return this.raceService.cancelRace({ raceId });
const presenter = await this.raceService.cancelRace({ raceId });
const viewModel = presenter.viewModel;
if (!viewModel.success) {
throw new InternalServerErrorException(viewModel.message ?? 'Failed to cancel race');
}
}
@Post(':raceId/complete')
@@ -134,7 +158,12 @@ export class RaceController {
@ApiParam({ name: 'raceId', description: 'Race ID' })
@ApiResponse({ status: 200, description: 'Successfully completed race' })
async completeRace(@Param('raceId') raceId: string): Promise<void> {
return this.raceService.completeRace({ raceId });
const presenter = await this.raceService.completeRace({ raceId });
const viewModel = presenter.viewModel;
if (!viewModel.success) {
throw new InternalServerErrorException(viewModel.message ?? 'Failed to complete race');
}
}
@Post(':raceId/reopen')
@@ -143,7 +172,12 @@ export class RaceController {
@ApiParam({ name: 'raceId', description: 'Race ID' })
@ApiResponse({ status: 200, description: 'Successfully re-opened race' })
async reopenRace(@Param('raceId') raceId: string): Promise<void> {
return this.raceService.reopenRace({ raceId });
const presenter = await this.raceService.reopenRace({ raceId });
const viewModel = presenter.viewModel;
if (!viewModel.success) {
throw new InternalServerErrorException(viewModel.message ?? 'Failed to re-open race');
}
}
@Post(':raceId/import-results')
@@ -155,7 +189,8 @@ export class RaceController {
@Param('raceId') raceId: string,
@Body() body: Omit<ImportRaceResultsDTO, 'raceId'>,
): Promise<ImportRaceResultsSummaryDTO> {
return this.raceService.importRaceResults({ raceId, ...body });
const presenter = await this.raceService.importRaceResults({ raceId, ...body });
return presenter.viewModel;
}
@Post('protests/file')
@@ -163,7 +198,12 @@ export class RaceController {
@ApiOperation({ summary: 'File a protest' })
@ApiResponse({ status: 200, description: 'Protest filed successfully' })
async fileProtest(@Body() body: FileProtestCommandDTO): Promise<void> {
return this.raceService.fileProtest(body);
const presenter = await this.raceService.fileProtest(body);
const viewModel = presenter.viewModel;
if (!viewModel.success) {
throw new InternalServerErrorException(viewModel.message ?? 'Failed to file protest');
}
}
@Post('penalties/quick')
@@ -171,7 +211,12 @@ export class RaceController {
@ApiOperation({ summary: 'Apply a quick penalty' })
@ApiResponse({ status: 200, description: 'Penalty applied successfully' })
async applyQuickPenalty(@Body() body: QuickPenaltyCommandDTO): Promise<void> {
return this.raceService.applyQuickPenalty(body);
const presenter = await this.raceService.applyQuickPenalty(body);
const viewModel = presenter.viewModel;
if (!viewModel.success) {
throw new InternalServerErrorException(viewModel.message ?? 'Failed to apply quick penalty');
}
}
@Post('penalties/apply')
@@ -179,7 +224,12 @@ export class RaceController {
@ApiOperation({ summary: 'Apply a penalty' })
@ApiResponse({ status: 200, description: 'Penalty applied successfully' })
async applyPenalty(@Body() body: ApplyPenaltyCommandDTO): Promise<void> {
return this.raceService.applyPenalty(body);
const presenter = await this.raceService.applyPenalty(body);
const viewModel = presenter.viewModel;
if (!viewModel.success) {
throw new InternalServerErrorException(viewModel.message ?? 'Failed to apply penalty');
}
}
@Post('protests/defense/request')
@@ -187,6 +237,11 @@ export class RaceController {
@ApiOperation({ summary: 'Request protest defense' })
@ApiResponse({ status: 200, description: 'Defense requested successfully' })
async requestProtestDefense(@Body() body: RequestProtestDefenseCommandDTO): Promise<void> {
return this.raceService.requestProtestDefense(body);
const presenter = await this.raceService.requestProtestDefense(body);
const viewModel = presenter.viewModel;
if (!viewModel.success) {
throw new InternalServerErrorException(viewModel.message ?? 'Failed to request protest defense');
}
}
}

View File

@@ -13,7 +13,6 @@ import type { ILeagueMembershipRepository } from '@core/racing/domain/repositori
import type { IPenaltyRepository } from '@core/racing/domain/repositories/IPenaltyRepository';
import type { IProtestRepository } from '@core/racing/domain/repositories/IProtestRepository';
import type { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider';
import type { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
// Import concrete in-memory implementations
import { InMemoryRaceRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRepository';

View File

@@ -0,0 +1,168 @@
import { RaceService } from './RaceService';
import { GetAllRacesUseCase } from '@core/racing/application/use-cases/GetAllRacesUseCase';
import { GetTotalRacesUseCase } from '@core/racing/application/use-cases/GetTotalRacesUseCase';
import { ImportRaceResultsApiUseCase } from '@core/racing/application/use-cases/ImportRaceResultsApiUseCase';
import { GetRaceDetailUseCase } from '@core/racing/application/use-cases/GetRaceDetailUseCase';
import { GetRacesPageDataUseCase } from '@core/racing/application/use-cases/GetRacesPageDataUseCase';
import { GetAllRacesPageDataUseCase } from '@core/racing/application/use-cases/GetAllRacesPageDataUseCase';
import { GetRaceResultsDetailUseCase } from '@core/racing/application/use-cases/GetRaceResultsDetailUseCase';
import { GetRaceWithSOFUseCase } from '@core/racing/application/use-cases/GetRaceWithSOFUseCase';
import { GetRaceProtestsUseCase } from '@core/racing/application/use-cases/GetRaceProtestsUseCase';
import { GetRacePenaltiesUseCase } from '@core/racing/application/use-cases/GetRacePenaltiesUseCase';
import { RegisterForRaceUseCase } from '@core/racing/application/use-cases/RegisterForRaceUseCase';
import { WithdrawFromRaceUseCase } from '@core/racing/application/use-cases/WithdrawFromRaceUseCase';
import { CancelRaceUseCase } from '@core/racing/application/use-cases/CancelRaceUseCase';
import { CompleteRaceUseCase } from '@core/racing/application/use-cases/CompleteRaceUseCase';
import { FileProtestUseCase } from '@core/racing/application/use-cases/FileProtestUseCase';
import { QuickPenaltyUseCase } from '@core/racing/application/use-cases/QuickPenaltyUseCase';
import { ApplyPenaltyUseCase } from '@core/racing/application/use-cases/ApplyPenaltyUseCase';
import { RequestProtestDefenseUseCase } from '@core/racing/application/use-cases/RequestProtestDefenseUseCase';
import { ReviewProtestUseCase } from '@core/racing/application/use-cases/ReviewProtestUseCase';
import { ReopenRaceUseCase } from '@core/racing/application/use-cases/ReopenRaceUseCase';
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
import type { Logger } from '@core/shared/application/Logger';
import type { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider';
import type { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
import { Result } from '@core/shared/application/Result';
// Minimal happy-path coverage to assert presenter usage
describe('RaceService', () => {
let service: RaceService;
let getAllRacesUseCase: jest.Mocked<GetAllRacesUseCase>;
let getTotalRacesUseCase: jest.Mocked<GetTotalRacesUseCase>;
let importRaceResultsApiUseCase: jest.Mocked<ImportRaceResultsApiUseCase>;
let getRaceDetailUseCase: jest.Mocked<GetRaceDetailUseCase>;
let getRacesPageDataUseCase: jest.Mocked<GetRacesPageDataUseCase>;
let getAllRacesPageDataUseCase: jest.Mocked<GetAllRacesPageDataUseCase>;
let getRaceResultsDetailUseCase: jest.Mocked<GetRaceResultsDetailUseCase>;
let getRaceWithSOFUseCase: jest.Mocked<GetRaceWithSOFUseCase>;
let getRaceProtestsUseCase: jest.Mocked<GetRaceProtestsUseCase>;
let getRacePenaltiesUseCase: jest.Mocked<GetRacePenaltiesUseCase>;
let registerForRaceUseCase: jest.Mocked<RegisterForRaceUseCase>;
let withdrawFromRaceUseCase: jest.Mocked<WithdrawFromRaceUseCase>;
let cancelRaceUseCase: jest.Mocked<CancelRaceUseCase>;
let completeRaceUseCase: jest.Mocked<CompleteRaceUseCase>;
let fileProtestUseCase: jest.Mocked<FileProtestUseCase>;
let quickPenaltyUseCase: jest.Mocked<QuickPenaltyUseCase>;
let applyPenaltyUseCase: jest.Mocked<ApplyPenaltyUseCase>;
let requestProtestDefenseUseCase: jest.Mocked<RequestProtestDefenseUseCase>;
let reviewProtestUseCase: jest.Mocked<ReviewProtestUseCase>;
let reopenRaceUseCase: jest.Mocked<ReopenRaceUseCase>;
let leagueRepository: jest.Mocked<ILeagueRepository>;
let logger: jest.Mocked<Logger>;
let driverRatingProvider: jest.Mocked<DriverRatingProvider>;
let imageService: jest.Mocked<IImageServicePort>;
beforeEach(() => {
getAllRacesUseCase = { execute: jest.fn() } as jest.Mocked<GetAllRacesUseCase>;
getTotalRacesUseCase = { execute: jest.fn() } as jest.Mocked<GetTotalRacesUseCase>;
importRaceResultsApiUseCase = { execute: jest.fn() } as jest.Mocked<ImportRaceResultsApiUseCase>;
getRaceDetailUseCase = { execute: jest.fn() } as jest.Mocked<GetRaceDetailUseCase>;
getRacesPageDataUseCase = { execute: jest.fn() } as jest.Mocked<GetRacesPageDataUseCase>;
getAllRacesPageDataUseCase = { execute: jest.fn() } as jest.Mocked<GetAllRacesPageDataUseCase>;
getRaceResultsDetailUseCase = { execute: jest.fn() } as jest.Mocked<GetRaceResultsDetailUseCase>;
getRaceWithSOFUseCase = { execute: jest.fn() } as jest.Mocked<GetRaceWithSOFUseCase>;
getRaceProtestsUseCase = { execute: jest.fn() } as jest.Mocked<GetRaceProtestsUseCase>;
getRacePenaltiesUseCase = { execute: jest.fn() } as jest.Mocked<GetRacePenaltiesUseCase>;
registerForRaceUseCase = { execute: jest.fn() } as jest.Mocked<RegisterForRaceUseCase>;
withdrawFromRaceUseCase = { execute: jest.fn() } as jest.Mocked<WithdrawFromRaceUseCase>;
cancelRaceUseCase = { execute: jest.fn() } as jest.Mocked<CancelRaceUseCase>;
completeRaceUseCase = { execute: jest.fn() } as jest.Mocked<CompleteRaceUseCase>;
fileProtestUseCase = { execute: jest.fn() } as jest.Mocked<FileProtestUseCase>;
quickPenaltyUseCase = { execute: jest.fn() } as jest.Mocked<QuickPenaltyUseCase>;
applyPenaltyUseCase = { execute: jest.fn() } as jest.Mocked<ApplyPenaltyUseCase>;
requestProtestDefenseUseCase = { execute: jest.fn() } as jest.Mocked<RequestProtestDefenseUseCase>;
reviewProtestUseCase = { execute: jest.fn() } as jest.Mocked<ReviewProtestUseCase>;
reopenRaceUseCase = { execute: jest.fn() } as jest.Mocked<ReopenRaceUseCase>;
leagueRepository = {
findAll: jest.fn(),
} as jest.Mocked<ILeagueRepository>;
logger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
} as jest.Mocked<Logger>;
driverRatingProvider = {
getDriverRating: jest.fn(),
} as jest.Mocked<DriverRatingProvider>;
imageService = {
getDriverAvatar: jest.fn(),
getTeamLogo: jest.fn(),
getLeagueCover: jest.fn(),
getLeagueLogo: jest.fn(),
} as jest.Mocked<IImageServicePort>;
service = new RaceService(
getAllRacesUseCase,
getTotalRacesUseCase,
importRaceResultsApiUseCase,
getRaceDetailUseCase,
getRacesPageDataUseCase,
getAllRacesPageDataUseCase,
getRaceResultsDetailUseCase,
getRaceWithSOFUseCase,
getRaceProtestsUseCase,
getRacePenaltiesUseCase,
registerForRaceUseCase,
withdrawFromRaceUseCase,
cancelRaceUseCase,
completeRaceUseCase,
fileProtestUseCase,
quickPenaltyUseCase,
applyPenaltyUseCase,
requestProtestDefenseUseCase,
reviewProtestUseCase,
reopenRaceUseCase,
leagueRepository,
logger,
driverRatingProvider,
imageService,
);
});
it('getAllRaces should return presenter with view model', async () => {
const output = {
races: [],
totalCount: 0,
};
(getAllRacesUseCase.execute as jest.Mock).mockResolvedValue(Result.ok(output));
const presenter = await service.getAllRaces();
const viewModel = presenter.getViewModel();
expect(getAllRacesUseCase.execute).toHaveBeenCalledWith();
expect(viewModel).not.toBeNull();
expect(viewModel).toMatchObject({ totalCount: 0 });
});
it('registerForRace should map success into CommandResultPresenter', async () => {
(registerForRaceUseCase.execute as jest.Mock).mockResolvedValue(Result.ok({}));
const presenter = await service.registerForRace({
raceId: 'race-1',
driverId: 'driver-1',
} as { raceId: string; driverId: string });
expect(registerForRaceUseCase.execute).toHaveBeenCalledWith({ raceId: 'race-1', driverId: 'driver-1' });
expect(presenter.viewModel.success).toBe(true);
});
it('registerForRace should map error into CommandResultPresenter', async () => {
(registerForRaceUseCase.execute as jest.Mock).mockResolvedValue(Result.err({ code: 'FAILED_TO_REGISTER_FOR_RACE' as const }));
const presenter = await service.registerForRace({
raceId: 'race-1',
driverId: 'driver-1',
} as { raceId: string; driverId: string });
expect(presenter.viewModel.success).toBe(false);
expect(presenter.viewModel.errorCode).toBe('FAILED_TO_REGISTER_FOR_RACE');
});
});

View File

@@ -1,5 +1,4 @@
import { ConflictException, Inject, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import type { AllRacesPageViewModel } from '@core/racing/application/presenters/IGetAllRacesPresenter';
import { Inject, Injectable } from '@nestjs/common';
import type { RaceDetailOutputPort } from '@core/racing/application/ports/output/RaceDetailOutputPort';
import type { RacesPageOutputPort } from '@core/racing/application/ports/output/RacesPageOutputPort';
import type { RaceResultsDetailOutputPort } from '@core/racing/application/ports/output/RaceResultsDetailOutputPort';
@@ -13,17 +12,11 @@ import { RegisterForRaceParamsDTO } from './dtos/RegisterForRaceParamsDTO';
import { WithdrawFromRaceParamsDTO } from './dtos/WithdrawFromRaceParamsDTO';
import { RaceActionParamsDTO } from './dtos/RaceActionParamsDTO';
import { ImportRaceResultsDTO } from './dtos/ImportRaceResultsDTO';
import { AllRacesPageDTO } from './dtos/AllRacesPageDTO';
import { RaceStatsDTO } from './dtos/RaceStatsDTO';
import { RacePenaltiesDTO } from './dtos/RacePenaltiesDTO';
import { RaceProtestsDTO } from './dtos/RaceProtestsDTO';
import { RaceResultsDetailDTO } from './dtos/RaceResultsDetailDTO';
// Core imports
import type { Logger } from '@core/shared/application/Logger';
import { Result } from '@core/shared/application/Result';
import { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider';
import { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
import type { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
// Use cases
@@ -41,7 +34,6 @@ import { RegisterForRaceUseCase } from '@core/racing/application/use-cases/Regis
import { WithdrawFromRaceUseCase } from '@core/racing/application/use-cases/WithdrawFromRaceUseCase';
import { CancelRaceUseCase } from '@core/racing/application/use-cases/CancelRaceUseCase';
import { CompleteRaceUseCase } from '@core/racing/application/use-cases/CompleteRaceUseCase';
import { ImportRaceResultsUseCase } from '@core/racing/application/use-cases/ImportRaceResultsUseCase';
import { FileProtestUseCase } from '@core/racing/application/use-cases/FileProtestUseCase';
import { QuickPenaltyUseCase } from '@core/racing/application/use-cases/QuickPenaltyUseCase';
import { ApplyPenaltyUseCase } from '@core/racing/application/use-cases/ApplyPenaltyUseCase';
@@ -53,6 +45,14 @@ import { ReopenRaceUseCase } from '@core/racing/application/use-cases/ReopenRace
import { GetAllRacesPresenter } from './presenters/GetAllRacesPresenter';
import { GetTotalRacesPresenter } from './presenters/GetTotalRacesPresenter';
import { ImportRaceResultsApiPresenter } from './presenters/ImportRaceResultsApiPresenter';
import { RaceDetailPresenter } from './presenters/RaceDetailPresenter';
import { RacesPageDataPresenter } from './presenters/RacesPageDataPresenter';
import { AllRacesPageDataPresenter } from './presenters/AllRacesPageDataPresenter';
import { RaceResultsDetailPresenter } from './presenters/RaceResultsDetailPresenter';
import { RaceWithSOFPresenter } from './presenters/RaceWithSOFPresenter';
import { RaceProtestsPresenter } from './presenters/RaceProtestsPresenter';
import { RacePenaltiesPresenter } from './presenters/RacePenaltiesPresenter';
import { CommandResultPresenter } from './presenters/CommandResultPresenter';
// Command DTOs
import { FileProtestCommandDTO } from './dtos/FileProtestCommandDTO';
@@ -93,15 +93,21 @@ export class RaceService {
@Inject(IMAGE_SERVICE_TOKEN) private readonly imageService: IImageServicePort,
) {}
async getAllRaces(): Promise<AllRacesPageViewModel> {
async getAllRaces(): Promise<GetAllRacesPresenter> {
this.logger.debug('[RaceService] Fetching all races.');
const result = await this.getAllRacesUseCase.execute();
if (result.isErr()) {
throw new Error('Failed to get all races');
}
const presenter = new GetAllRacesPresenter();
await this.getAllRacesUseCase.execute({}, presenter);
return presenter.getViewModel()!;
await presenter.present(result.unwrap());
return presenter;
}
async getTotalRaces(): Promise<RaceStatsDTO> {
async getTotalRaces(): Promise<GetTotalRacesPresenter> {
this.logger.debug('[RaceService] Fetching total races count.');
const result = await this.getTotalRacesUseCase.execute();
if (result.isErr()) {
@@ -109,10 +115,10 @@ export class RaceService {
}
const presenter = new GetTotalRacesPresenter();
presenter.present(result.unwrap());
return presenter.getViewModel()!;
return presenter;
}
async importRaceResults(input: ImportRaceResultsDTO): Promise<ImportRaceResultsSummaryDTO> {
async importRaceResults(input: ImportRaceResultsDTO): Promise<ImportRaceResultsApiPresenter> {
this.logger.debug('Importing race results:', input);
const result = await this.importRaceResultsApiUseCase.execute({ raceId: input.raceId, resultsFileContent: input.resultsFileContent });
if (result.isErr()) {
@@ -120,10 +126,10 @@ export class RaceService {
}
const presenter = new ImportRaceResultsApiPresenter();
presenter.present(result.unwrap());
return presenter.getViewModel()!;
return presenter;
}
async getRaceDetail(params: GetRaceDetailParamsDTO): Promise<RaceDetailDTO> {
async getRaceDetail(params: GetRaceDetailParamsDTO): Promise<RaceDetailPresenter> {
this.logger.debug('[RaceService] Fetching race detail:', params);
const result = await this.getRaceDetailUseCase.execute(params);
@@ -132,79 +138,12 @@ export class RaceService {
throw new Error('Failed to get race detail');
}
const outputPort = result.value as RaceDetailOutputPort;
// Map to DTO
const raceDTO = outputPort.race
? {
id: outputPort.race.id,
leagueId: outputPort.race.leagueId,
track: outputPort.race.track,
car: outputPort.race.car,
scheduledAt: outputPort.race.scheduledAt.toISOString(),
sessionType: outputPort.race.sessionType,
status: outputPort.race.status,
strengthOfField: outputPort.race.strengthOfField ?? null,
registeredCount: outputPort.race.registeredCount ?? undefined,
maxParticipants: outputPort.race.maxParticipants ?? undefined,
}
: null;
const leagueDTO = outputPort.league
? {
id: outputPort.league.id.toString(),
name: outputPort.league.name.toString(),
description: outputPort.league.description.toString(),
settings: {
maxDrivers: outputPort.league.settings.maxDrivers ?? undefined,
qualifyingFormat: outputPort.league.settings.qualifyingFormat ?? undefined,
},
}
: null;
const entryListDTO = await Promise.all(
outputPort.drivers.map(async driver => {
const ratingResult = await this.driverRatingProvider.getDriverRating({ driverId: driver.id });
const avatarResult = await this.imageService.getDriverAvatar({ driverId: driver.id });
return {
id: driver.id,
name: driver.name.toString(),
country: driver.country.toString(),
avatarUrl: avatarResult.avatarUrl,
rating: ratingResult.rating,
isCurrentUser: driver.id === params.driverId,
};
}),
);
const registrationDTO = {
isUserRegistered: outputPort.isUserRegistered,
canRegister: outputPort.canRegister,
};
const userResultDTO = outputPort.userResult
? {
position: outputPort.userResult.position.toNumber(),
startPosition: outputPort.userResult.startPosition.toNumber(),
incidents: outputPort.userResult.incidents.toNumber(),
fastestLap: outputPort.userResult.fastestLap.toNumber(),
positionChange: outputPort.userResult.getPositionChange(),
isPodium: outputPort.userResult.isPodium(),
isClean: outputPort.userResult.isClean(),
ratingChange: this.calculateRatingChange(outputPort.userResult.position.toNumber()),
}
: null;
return {
race: raceDTO,
league: leagueDTO,
entryList: entryListDTO,
registration: registrationDTO,
userResult: userResultDTO,
};
const presenter = new RaceDetailPresenter(this.driverRatingProvider, this.imageService);
await presenter.present(result.value as RaceDetailOutputPort, params);
return presenter;
}
async getRacesPageData(): Promise<RacesPageDataDTO> {
async getRacesPageData(): Promise<RacesPageDataPresenter> {
this.logger.debug('[RaceService] Fetching races page data.');
const result = await this.getRacesPageDataUseCase.execute();
@@ -213,33 +152,12 @@ export class RaceService {
throw new Error('Failed to get races page data');
}
const outputPort = result.value as RacesPageOutputPort;
// Fetch leagues for league names
const allLeagues = await this.leagueRepository.findAll();
const leagueMap = new Map(allLeagues.map(l => [l.id, l.name]));
// Map to DTO
const racesDTO = outputPort.races.map(race => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt.toISOString(),
status: race.status,
leagueId: race.leagueId,
leagueName: leagueMap.get(race.leagueId) ?? 'Unknown League',
strengthOfField: race.strengthOfField,
isUpcoming: race.scheduledAt > new Date(),
isLive: race.status === 'running',
isPast: race.scheduledAt < new Date() && race.status === 'completed',
}));
return {
races: racesDTO,
};
const presenter = new RacesPageDataPresenter(this.leagueRepository);
await presenter.present(result.value as RacesPageOutputPort);
return presenter;
}
async getAllRacesPageData(): Promise<AllRacesPageDTO> {
async getAllRacesPageData(): Promise<AllRacesPageDataPresenter> {
this.logger.debug('[RaceService] Fetching all races page data.');
const result = await this.getAllRacesPageDataUseCase.execute();
@@ -248,10 +166,12 @@ export class RaceService {
throw new Error('Failed to get all races page data');
}
return result.value as AllRacesPageDTO;
const presenter = new AllRacesPageDataPresenter();
presenter.present(result.value);
return presenter;
}
async getRaceResultsDetail(raceId: string): Promise<RaceResultsDetailDTO> {
async getRaceResultsDetail(raceId: string): Promise<RaceResultsDetailPresenter> {
this.logger.debug('[RaceService] Fetching race results detail:', { raceId });
const result = await this.getRaceResultsDetailUseCase.execute({ raceId });
@@ -260,43 +180,12 @@ export class RaceService {
throw new Error('Failed to get race results detail');
}
const outputPort = result.value as RaceResultsDetailOutputPort;
// Create a map of driverId to driver for easy lookup
const driverMap = new Map(outputPort.drivers.map(driver => [driver.id, driver]));
const resultsDTO = await Promise.all(
outputPort.results.map(async singleResult => {
const driver = driverMap.get(singleResult.driverId.toString());
if (!driver) {
throw new Error(`Driver not found for result: ${singleResult.driverId}`);
}
const avatarResult = await this.imageService.getDriverAvatar({ driverId: driver.id });
return {
driverId: singleResult.driverId.toString(),
driverName: driver.name.toString(),
avatarUrl: avatarResult.avatarUrl,
position: singleResult.position.toNumber(),
startPosition: singleResult.startPosition.toNumber(),
incidents: singleResult.incidents.toNumber(),
fastestLap: singleResult.fastestLap.toNumber(),
positionChange: singleResult.getPositionChange(),
isPodium: singleResult.isPodium(),
isClean: singleResult.isClean(),
};
}),
);
return {
raceId: outputPort.race.id,
track: outputPort.race.track,
results: resultsDTO,
};
const presenter = new RaceResultsDetailPresenter(this.imageService);
await presenter.present(result.value as RaceResultsDetailOutputPort);
return presenter;
}
async getRaceWithSOF(raceId: string): Promise<RaceWithSOFDTO> {
async getRaceWithSOF(raceId: string): Promise<RaceWithSOFPresenter> {
this.logger.debug('[RaceService] Fetching race with SOF:', { raceId });
const result = await this.getRaceWithSOFUseCase.execute({ raceId });
@@ -305,17 +194,12 @@ export class RaceService {
throw new Error('Failed to get race with SOF');
}
const outputPort = result.value as RaceWithSOFOutputPort;
// Map to DTO
return {
id: outputPort.id,
track: outputPort.track,
strengthOfField: outputPort.strengthOfField,
};
const presenter = new RaceWithSOFPresenter();
presenter.present(result.value as RaceWithSOFOutputPort);
return presenter;
}
async getRaceProtests(raceId: string): Promise<RaceProtestsDTO> {
async getRaceProtests(raceId: string): Promise<RaceProtestsPresenter> {
this.logger.debug('[RaceService] Fetching race protests:', { raceId });
const result = await this.getRaceProtestsUseCase.execute({ raceId });
@@ -324,32 +208,12 @@ export class RaceService {
throw new Error('Failed to get race protests');
}
const outputPort = result.value as RaceProtestsOutputPort;
const protestsDTO = outputPort.protests.map(protest => ({
id: protest.id,
protestingDriverId: protest.protestingDriverId,
accusedDriverId: protest.accusedDriverId,
incident: {
lap: protest.incident.lap,
description: protest.incident.description,
},
status: protest.status,
filedAt: protest.filedAt.toISOString(),
}));
const driverMap: Record<string, string> = {};
outputPort.drivers.forEach(driver => {
driverMap[driver.id] = driver.name.toString();
});
return {
protests: protestsDTO,
driverMap,
};
const presenter = new RaceProtestsPresenter();
presenter.present(result.value as RaceProtestsOutputPort);
return presenter;
}
async getRacePenalties(raceId: string): Promise<RacePenaltiesDTO> {
async getRacePenalties(raceId: string): Promise<RacePenaltiesPresenter> {
this.logger.debug('[RaceService] Fetching race penalties:', { raceId });
const result = await this.getRacePenaltiesUseCase.execute({ raceId });
@@ -358,148 +222,175 @@ export class RaceService {
throw new Error('Failed to get race penalties');
}
const outputPort = result.value as RacePenaltiesOutputPort;
const penaltiesDTO = outputPort.penalties.map(penalty => ({
id: penalty.id,
driverId: penalty.driverId,
type: penalty.type,
value: penalty.value ?? 0,
reason: penalty.reason,
issuedBy: penalty.issuedBy,
issuedAt: penalty.issuedAt.toISOString(),
notes: penalty.notes,
}));
const driverMap: Record<string, string> = {};
outputPort.drivers.forEach(driver => {
driverMap[driver.id] = driver.name.toString();
});
return {
penalties: penaltiesDTO,
driverMap,
};
const presenter = new RacePenaltiesPresenter();
presenter.present(result.value as RacePenaltiesOutputPort);
return presenter;
}
async registerForRace(params: RegisterForRaceParamsDTO): Promise<void> {
async registerForRace(params: RegisterForRaceParamsDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Registering for race:', params);
const result = await this.registerForRaceUseCase.execute(params);
const presenter = new CommandResultPresenter();
if (result.isErr()) {
throw new Error('Failed to register for race');
const error = result.unwrapErr();
presenter.presentFailure(error.code ?? 'FAILED_TO_REGISTER_FOR_RACE', 'Failed to register for race');
return presenter;
}
presenter.presentSuccess();
return presenter;
}
async withdrawFromRace(params: WithdrawFromRaceParamsDTO): Promise<void> {
async withdrawFromRace(params: WithdrawFromRaceParamsDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Withdrawing from race:', params);
const result = await this.withdrawFromRaceUseCase.execute(params);
const presenter = new CommandResultPresenter();
if (result.isErr()) {
throw new Error('Failed to withdraw from race');
const error = result.unwrapErr();
presenter.presentFailure(error.code ?? 'FAILED_TO_WITHDRAW_FROM_RACE', 'Failed to withdraw from race');
return presenter;
}
presenter.presentSuccess();
return presenter;
}
async cancelRace(params: RaceActionParamsDTO): Promise<void> {
async cancelRace(params: RaceActionParamsDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Cancelling race:', params);
const result = await this.cancelRaceUseCase.execute({ raceId: params.raceId });
const presenter = new CommandResultPresenter();
if (result.isErr()) {
throw new Error('Failed to cancel race');
const error = result.unwrapErr();
presenter.presentFailure(error.code ?? 'FAILED_TO_CANCEL_RACE', 'Failed to cancel race');
return presenter;
}
presenter.presentSuccess();
return presenter;
}
async completeRace(params: RaceActionParamsDTO): Promise<void> {
async completeRace(params: RaceActionParamsDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Completing race:', params);
const result = await this.completeRaceUseCase.execute({ raceId: params.raceId });
const presenter = new CommandResultPresenter();
if (result.isErr()) {
throw new Error('Failed to complete race');
const error = result.unwrapErr();
presenter.presentFailure(error.code ?? 'FAILED_TO_COMPLETE_RACE', 'Failed to complete race');
return presenter;
}
presenter.presentSuccess();
return presenter;
}
async reopenRace(params: RaceActionParamsDTO): Promise<void> {
async reopenRace(params: RaceActionParamsDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Re-opening race:', params);
const result = await this.reopenRaceUseCase.execute({ raceId: params.raceId });
const presenter = new CommandResultPresenter();
if (result.isErr()) {
const errorCode = result.unwrapErr().code;
if (errorCode === 'RACE_NOT_FOUND') {
throw new NotFoundException('Race not found');
}
if (errorCode === 'CANNOT_REOPEN_RUNNING_RACE') {
throw new ConflictException('Cannot re-open a running race');
}
if (errorCode === 'RACE_ALREADY_SCHEDULED') {
this.logger.debug('[RaceService] Race is already scheduled, treating reopen as success.');
return;
presenter.presentSuccess('Race already scheduled');
return presenter;
}
throw new InternalServerErrorException(errorCode ?? 'UNEXPECTED_ERROR');
presenter.presentFailure(errorCode ?? 'UNEXPECTED_ERROR', 'Unexpected error while reopening race');
return presenter;
}
presenter.presentSuccess();
return presenter;
}
async fileProtest(command: FileProtestCommandDTO): Promise<void> {
async fileProtest(command: FileProtestCommandDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Filing protest:', command);
const result = await this.fileProtestUseCase.execute(command);
const presenter = new CommandResultPresenter();
if (result.isErr()) {
throw new Error('Failed to file protest');
const error = result.unwrapErr();
presenter.presentFailure(error.code ?? 'FAILED_TO_FILE_PROTEST', 'Failed to file protest');
return presenter;
}
presenter.presentSuccess();
return presenter;
}
async applyQuickPenalty(command: QuickPenaltyCommandDTO): Promise<void> {
async applyQuickPenalty(command: QuickPenaltyCommandDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Applying quick penalty:', command);
const result = await this.quickPenaltyUseCase.execute(command);
const presenter = new CommandResultPresenter();
if (result.isErr()) {
throw new Error('Failed to apply quick penalty');
const error = result.unwrapErr();
presenter.presentFailure(error.code ?? 'FAILED_TO_APPLY_QUICK_PENALTY', 'Failed to apply quick penalty');
return presenter;
}
presenter.presentSuccess();
return presenter;
}
async applyPenalty(command: ApplyPenaltyCommandDTO): Promise<void> {
async applyPenalty(command: ApplyPenaltyCommandDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Applying penalty:', command);
const result = await this.applyPenaltyUseCase.execute(command);
const presenter = new CommandResultPresenter();
if (result.isErr()) {
throw new Error('Failed to apply penalty');
const error = result.unwrapErr();
presenter.presentFailure(error.code ?? 'FAILED_TO_APPLY_PENALTY', 'Failed to apply penalty');
return presenter;
}
presenter.presentSuccess();
return presenter;
}
async requestProtestDefense(command: RequestProtestDefenseCommandDTO): Promise<void> {
async requestProtestDefense(command: RequestProtestDefenseCommandDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Requesting protest defense:', command);
const result = await this.requestProtestDefenseUseCase.execute(command);
const presenter = new CommandResultPresenter();
if (result.isErr()) {
throw new Error('Failed to request protest defense');
const error = result.unwrapErr();
presenter.presentFailure(error.code ?? 'FAILED_TO_REQUEST_PROTEST_DEFENSE', 'Failed to request protest defense');
return presenter;
}
presenter.presentSuccess();
return presenter;
}
async reviewProtest(command: ReviewProtestCommandDTO): Promise<void> {
async reviewProtest(command: ReviewProtestCommandDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Reviewing protest:', command);
const result = await this.reviewProtestUseCase.execute(command);
const presenter = new CommandResultPresenter();
if (result.isErr()) {
throw new Error('Failed to review protest');
const error = result.unwrapErr();
presenter.presentFailure(error.code ?? 'FAILED_TO_REVIEW_PROTEST', 'Failed to review protest');
return presenter;
}
}
private calculateRatingChange(position: number): number {
const baseChange = position <= 3 ? 25 : position <= 10 ? 10 : -5;
const positionBonus = Math.max(0, (20 - position) * 2);
return baseChange + positionBonus;
presenter.presentSuccess();
return presenter;
}
}

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNumber, IsOptional } from 'class-validator';
import { IsNumber } from 'class-validator';
import { DashboardDriverSummaryDTO } from './DashboardDriverSummaryDTO';
import { DashboardRaceSummaryDTO } from './DashboardRaceSummaryDTO';
import { DashboardRecentResultDTO } from './DashboardRecentResultDTO';

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsOptional, IsNumber, IsUrl } from 'class-validator';
import { IsString, IsNotEmpty, IsOptional, IsUrl } from 'class-validator';
export class FileProtestCommandDTO {
@ApiProperty()

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsBoolean, IsNumber } from 'class-validator';
import { IsString, IsBoolean } from 'class-validator';
export class RaceDetailEntryDTO {
@ApiProperty()

View File

@@ -0,0 +1,21 @@
import type { AllRacesPageDTO } from '../dtos/AllRacesPageDTO';
export class AllRacesPageDataPresenter {
private result: AllRacesPageDTO | null = null;
present(output: AllRacesPageDTO): void {
this.result = output;
}
getViewModel(): AllRacesPageDTO | null {
return this.result;
}
get viewModel(): AllRacesPageDTO {
if (!this.result) {
throw new Error('Presenter not presented');
}
return this.result;
}
}

View File

@@ -0,0 +1,36 @@
export interface CommandResultViewModel {
success: boolean;
errorCode?: string;
message?: string;
}
export class CommandResultPresenter {
private result: CommandResultViewModel | null = null;
presentSuccess(message?: string): void {
this.result = {
success: true,
message,
};
}
presentFailure(errorCode: string, message?: string): void {
this.result = {
success: false,
errorCode,
message,
};
}
getViewModel(): CommandResultViewModel | null {
return this.result;
}
get viewModel(): CommandResultViewModel {
if (!this.result) {
throw new Error('Presenter not presented');
}
return this.result;
}
}

View File

@@ -0,0 +1,107 @@
import type { RaceDetailOutputPort } from '@core/racing/application/ports/output/RaceDetailOutputPort';
import type { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider';
import type { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
import type { GetRaceDetailParamsDTO } from '../dtos/GetRaceDetailParamsDTO';
import type { RaceDetailDTO } from '../dtos/RaceDetailDTO';
import type { RaceDetailRaceDTO } from '../dtos/RaceDetailRaceDTO';
import type { RaceDetailLeagueDTO } from '../dtos/RaceDetailLeagueDTO';
import type { RaceDetailEntryDTO } from '../dtos/RaceDetailEntryDTO';
import type { RaceDetailRegistrationDTO } from '../dtos/RaceDetailRegistrationDTO';
import type { RaceDetailUserResultDTO } from '../dtos/RaceDetailUserResultDTO';
export class RaceDetailPresenter {
private result: RaceDetailDTO | null = null;
constructor(
private readonly driverRatingProvider: DriverRatingProvider,
private readonly imageService: IImageServicePort,
) {}
async present(outputPort: RaceDetailOutputPort, params: GetRaceDetailParamsDTO): Promise<void> {
const raceDTO: RaceDetailRaceDTO | null = outputPort.race
? {
id: outputPort.race.id,
leagueId: outputPort.race.leagueId,
track: outputPort.race.track,
car: outputPort.race.car,
scheduledAt: outputPort.race.scheduledAt.toISOString(),
sessionType: outputPort.race.sessionType,
status: outputPort.race.status,
strengthOfField: outputPort.race.strengthOfField ?? null,
registeredCount: outputPort.race.registeredCount ?? undefined,
maxParticipants: outputPort.race.maxParticipants ?? undefined,
}
: null;
const leagueDTO: RaceDetailLeagueDTO | null = outputPort.league
? {
id: outputPort.league.id.toString(),
name: outputPort.league.name.toString(),
description: outputPort.league.description.toString(),
settings: {
maxDrivers: outputPort.league.settings.maxDrivers ?? undefined,
qualifyingFormat: outputPort.league.settings.qualifyingFormat ?? undefined,
},
}
: null;
const entryListDTO: RaceDetailEntryDTO[] = await Promise.all(
outputPort.drivers.map(async driver => {
const ratingResult = await this.driverRatingProvider.getDriverRating({ driverId: driver.id });
const avatarResult = await this.imageService.getDriverAvatar({ driverId: driver.id });
return {
id: driver.id,
name: driver.name.toString(),
country: driver.country.toString(),
avatarUrl: avatarResult.avatarUrl,
rating: ratingResult.rating,
isCurrentUser: driver.id === params.driverId,
};
}),
);
const registrationDTO: RaceDetailRegistrationDTO = {
isUserRegistered: outputPort.isUserRegistered,
canRegister: outputPort.canRegister,
};
const userResultDTO: RaceDetailUserResultDTO | null = outputPort.userResult
? {
position: outputPort.userResult.position.toNumber(),
startPosition: outputPort.userResult.startPosition.toNumber(),
incidents: outputPort.userResult.incidents.toNumber(),
fastestLap: outputPort.userResult.fastestLap.toNumber(),
positionChange: outputPort.userResult.getPositionChange(),
isPodium: outputPort.userResult.isPodium(),
isClean: outputPort.userResult.isClean(),
ratingChange: this.calculateRatingChange(outputPort.userResult.position.toNumber()),
}
: null;
this.result = {
race: raceDTO,
league: leagueDTO,
entryList: entryListDTO,
registration: registrationDTO,
userResult: userResultDTO,
} as RaceDetailDTO;
}
getViewModel(): RaceDetailDTO | null {
return this.result;
}
get viewModel(): RaceDetailDTO {
if (!this.result) {
throw new Error('Presenter not presented');
}
return this.result;
}
private calculateRatingChange(position: number): number {
const baseChange = position <= 3 ? 25 : position <= 10 ? 10 : -5;
const positionBonus = Math.max(0, (20 - position) * 2);
return baseChange + positionBonus;
}
}

View File

@@ -0,0 +1,42 @@
import type { RacePenaltiesOutputPort } from '@core/racing/application/ports/output/RacePenaltiesOutputPort';
import type { RacePenaltiesDTO } from '../dtos/RacePenaltiesDTO';
import type { RacePenaltyDTO } from '../dtos/RacePenaltyDTO';
export class RacePenaltiesPresenter {
private result: RacePenaltiesDTO | null = null;
present(outputPort: RacePenaltiesOutputPort): void {
const penalties: RacePenaltyDTO[] = outputPort.penalties.map(penalty => ({
id: penalty.id,
driverId: penalty.driverId,
type: penalty.type,
value: penalty.value ?? 0,
reason: penalty.reason,
issuedBy: penalty.issuedBy,
issuedAt: penalty.issuedAt.toISOString(),
notes: penalty.notes,
} as RacePenaltyDTO));
const driverMap: Record<string, string> = {};
outputPort.drivers.forEach(driver => {
driverMap[driver.id] = driver.name.toString();
});
this.result = {
penalties,
driverMap,
} as RacePenaltiesDTO;
}
getViewModel(): RacePenaltiesDTO | null {
return this.result;
}
get viewModel(): RacePenaltiesDTO {
if (!this.result) {
throw new Error('Presenter not presented');
}
return this.result;
}
}

View File

@@ -0,0 +1,43 @@
import type { RaceProtestsOutputPort } from '@core/racing/application/ports/output/RaceProtestsOutputPort';
import type { RaceProtestsDTO } from '../dtos/RaceProtestsDTO';
import type { RaceProtestDTO } from '../dtos/RaceProtestDTO';
export class RaceProtestsPresenter {
private result: RaceProtestsDTO | null = null;
present(outputPort: RaceProtestsOutputPort): void {
const protests: RaceProtestDTO[] = outputPort.protests.map(protest => ({
id: protest.id,
protestingDriverId: protest.protestingDriverId,
accusedDriverId: protest.accusedDriverId,
incident: {
lap: protest.incident.lap,
description: protest.incident.description,
},
status: protest.status,
filedAt: protest.filedAt.toISOString(),
} as RaceProtestDTO));
const driverMap: Record<string, string> = {};
outputPort.drivers.forEach(driver => {
driverMap[driver.id] = driver.name.toString();
});
this.result = {
protests,
driverMap,
} as RaceProtestsDTO;
}
getViewModel(): RaceProtestsDTO | null {
return this.result;
}
get viewModel(): RaceProtestsDTO {
if (!this.result) {
throw new Error('Presenter not presented');
}
return this.result;
}
}

View File

@@ -0,0 +1,56 @@
import type { RaceResultsDetailOutputPort } from '@core/racing/application/ports/output/RaceResultsDetailOutputPort';
import type { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
import type { RaceResultsDetailDTO } from '../dtos/RaceResultsDetailDTO';
import type { RaceResultDTO } from '../dtos/RaceResultDTO';
export class RaceResultsDetailPresenter {
private result: RaceResultsDetailDTO | null = null;
constructor(private readonly imageService: IImageServicePort) {}
async present(outputPort: RaceResultsDetailOutputPort): Promise<void> {
const driverMap = new Map(outputPort.drivers.map(driver => [driver.id, driver]));
const results: RaceResultDTO[] = await Promise.all(
outputPort.results.map(async singleResult => {
const driver = driverMap.get(singleResult.driverId.toString());
if (!driver) {
throw new Error(`Driver not found for result: ${singleResult.driverId}`);
}
const avatarResult = await this.imageService.getDriverAvatar({ driverId: driver.id });
return {
driverId: singleResult.driverId.toString(),
driverName: driver.name.toString(),
avatarUrl: avatarResult.avatarUrl,
position: singleResult.position.toNumber(),
startPosition: singleResult.startPosition.toNumber(),
incidents: singleResult.incidents.toNumber(),
fastestLap: singleResult.fastestLap.toNumber(),
positionChange: singleResult.getPositionChange(),
isPodium: singleResult.isPodium(),
isClean: singleResult.isClean(),
} as RaceResultDTO;
}),
);
this.result = {
raceId: outputPort.race.id,
track: outputPort.race.track,
results,
} as RaceResultsDetailDTO;
}
getViewModel(): RaceResultsDetailDTO | null {
return this.result;
}
get viewModel(): RaceResultsDetailDTO {
if (!this.result) {
throw new Error('Presenter not presented');
}
return this.result;
}
}

View File

@@ -0,0 +1,26 @@
import type { RaceWithSOFOutputPort } from '@core/racing/application/ports/output/RaceWithSOFOutputPort';
import type { RaceWithSOFDTO } from '../dtos/RaceWithSOFDTO';
export class RaceWithSOFPresenter {
private result: RaceWithSOFDTO | null = null;
present(outputPort: RaceWithSOFOutputPort): void {
this.result = {
id: outputPort.id,
track: outputPort.track,
strengthOfField: outputPort.strengthOfField,
} as RaceWithSOFDTO;
}
getViewModel(): RaceWithSOFDTO | null {
return this.result;
}
get viewModel(): RaceWithSOFDTO {
if (!this.result) {
throw new Error('Presenter not presented');
}
return this.result;
}
}

View File

@@ -0,0 +1,43 @@
import type { RacesPageOutputPort } from '@core/racing/application/ports/output/RacesPageOutputPort';
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
import type { RacesPageDataDTO } from '../dtos/RacesPageDataDTO';
import type { RacesPageDataRaceDTO } from '../dtos/RacesPageDataRaceDTO';
export class RacesPageDataPresenter {
private result: RacesPageDataDTO | null = null;
constructor(private readonly leagueRepository: ILeagueRepository) {}
async present(outputPort: RacesPageOutputPort): Promise<void> {
const allLeagues = await this.leagueRepository.findAll();
const leagueMap = new Map(allLeagues.map(l => [l.id, l.name]));
const races: RacesPageDataRaceDTO[] = outputPort.races.map(race => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt.toISOString(),
status: race.status,
leagueId: race.leagueId,
leagueName: leagueMap.get(race.leagueId) ?? 'Unknown League',
strengthOfField: race.strengthOfField,
isUpcoming: race.scheduledAt > new Date(),
isLive: race.status === 'running',
isPast: race.scheduledAt < new Date() && race.status === 'completed',
}));
this.result = { races } as RacesPageDataDTO;
}
getViewModel(): RacesPageDataDTO | null {
return this.result;
}
get viewModel(): RacesPageDataDTO {
if (!this.result) {
throw new Error('Presenter not presented');
}
return this.result;
}
}

View File

@@ -23,6 +23,11 @@ describe('SponsorController', () => {
getPendingSponsorshipRequests: vi.fn(),
acceptSponsorshipRequest: vi.fn(),
rejectSponsorshipRequest: vi.fn(),
getSponsorBilling: vi.fn(),
getAvailableLeagues: vi.fn(),
getLeagueDetail: vi.fn(),
getSponsorSettings: vi.fn(),
updateSponsorSettings: vi.fn(),
},
},
],
@@ -35,7 +40,7 @@ describe('SponsorController', () => {
describe('getEntitySponsorshipPricing', () => {
it('should return sponsorship pricing', async () => {
const mockResult = { entityType: 'season', entityId: 'season-1', pricing: [] };
sponsorService.getEntitySponsorshipPricing.mockResolvedValue(mockResult);
sponsorService.getEntitySponsorshipPricing.mockResolvedValue({ viewModel: mockResult } as any);
const result = await controller.getEntitySponsorshipPricing();
@@ -47,7 +52,7 @@ describe('SponsorController', () => {
describe('getSponsors', () => {
it('should return sponsors list', async () => {
const mockResult = { sponsors: [] };
sponsorService.getSponsors.mockResolvedValue(mockResult);
sponsorService.getSponsors.mockResolvedValue({ viewModel: mockResult } as any);
const result = await controller.getSponsors();
@@ -59,10 +64,10 @@ describe('SponsorController', () => {
describe('createSponsor', () => {
it('should create sponsor', async () => {
const input = { name: 'Test Sponsor', contactEmail: 'test@example.com' };
const mockResult = { id: 'sponsor-1', name: 'Test Sponsor' };
sponsorService.createSponsor.mockResolvedValue(mockResult);
const mockResult = { sponsor: { id: 's1', name: 'Test Sponsor' } };
sponsorService.createSponsor.mockResolvedValue({ viewModel: mockResult } as any);
const result = await controller.createSponsor(input);
const result = await controller.createSponsor(input as any);
expect(result).toEqual(mockResult);
expect(sponsorService.createSponsor).toHaveBeenCalledWith(input);
@@ -71,9 +76,9 @@ describe('SponsorController', () => {
describe('getSponsorDashboard', () => {
it('should return sponsor dashboard', async () => {
const sponsorId = 'sponsor-1';
const mockResult = { sponsorId, metrics: {}, sponsoredLeagues: [] };
sponsorService.getSponsorDashboard.mockResolvedValue(mockResult);
const sponsorId = 's1';
const mockResult = { sponsorId, metrics: {} as any, sponsoredLeagues: [], investment: {} as any };
sponsorService.getSponsorDashboard.mockResolvedValue({ viewModel: mockResult } as any);
const result = await controller.getSponsorDashboard(sponsorId);
@@ -82,8 +87,8 @@ describe('SponsorController', () => {
});
it('should return null when sponsor not found', async () => {
const sponsorId = 'sponsor-1';
sponsorService.getSponsorDashboard.mockResolvedValue(null);
const sponsorId = 's1';
sponsorService.getSponsorDashboard.mockResolvedValue({ viewModel: null } as any);
const result = await controller.getSponsorDashboard(sponsorId);
@@ -93,9 +98,20 @@ describe('SponsorController', () => {
describe('getSponsorSponsorships', () => {
it('should return sponsor sponsorships', async () => {
const sponsorId = 'sponsor-1';
const mockResult = { sponsorId, sponsorships: [] };
sponsorService.getSponsorSponsorships.mockResolvedValue(mockResult);
const sponsorId = 's1';
const mockResult = {
sponsorId,
sponsorName: 'S1',
sponsorships: [],
summary: {
totalSponsorships: 0,
activeSponsorships: 0,
totalInvestment: 0,
totalPlatformFees: 0,
currency: 'USD',
},
};
sponsorService.getSponsorSponsorships.mockResolvedValue({ viewModel: mockResult } as any);
const result = await controller.getSponsorSponsorships(sponsorId);
@@ -104,8 +120,8 @@ describe('SponsorController', () => {
});
it('should return null when sponsor not found', async () => {
const sponsorId = 'sponsor-1';
sponsorService.getSponsorSponsorships.mockResolvedValue(null);
const sponsorId = 's1';
sponsorService.getSponsorSponsorships.mockResolvedValue({ viewModel: null } as any);
const result = await controller.getSponsorSponsorships(sponsorId);
@@ -115,9 +131,9 @@ describe('SponsorController', () => {
describe('getSponsor', () => {
it('should return sponsor', async () => {
const sponsorId = 'sponsor-1';
const mockResult = { id: sponsorId, name: 'Test Sponsor' };
sponsorService.getSponsor.mockResolvedValue(mockResult);
const sponsorId = 's1';
const mockResult = { sponsor: { id: sponsorId, name: 'S1' } };
sponsorService.getSponsor.mockResolvedValue({ viewModel: mockResult } as any);
const result = await controller.getSponsor(sponsorId);
@@ -126,8 +142,8 @@ describe('SponsorController', () => {
});
it('should return null when sponsor not found', async () => {
const sponsorId = 'sponsor-1';
sponsorService.getSponsor.mockResolvedValue(null);
const sponsorId = 's1';
sponsorService.getSponsor.mockResolvedValue({ viewModel: null } as any);
const result = await controller.getSponsor(sponsorId);
@@ -138,8 +154,13 @@ describe('SponsorController', () => {
describe('getPendingSponsorshipRequests', () => {
it('should return pending sponsorship requests', async () => {
const query = { entityType: 'season' as const, entityId: 'season-1' };
const mockResult = { entityType: 'season', entityId: 'season-1', requests: [], totalCount: 0 };
sponsorService.getPendingSponsorshipRequests.mockResolvedValue(mockResult);
const mockResult = {
entityType: 'season',
entityId: 'season-1',
requests: [],
totalCount: 0,
};
sponsorService.getPendingSponsorshipRequests.mockResolvedValue({ viewModel: mockResult } as any);
const result = await controller.getPendingSponsorshipRequests(query);
@@ -150,30 +171,33 @@ describe('SponsorController', () => {
describe('acceptSponsorshipRequest', () => {
it('should accept sponsorship request', async () => {
const requestId = 'request-1';
const input = { respondedBy: 'user-1' };
const requestId = 'r1';
const input = { respondedBy: 'u1' };
const mockResult = {
requestId,
sponsorshipId: 'sponsorship-1',
sponsorshipId: 'sp1',
status: 'accepted' as const,
acceptedAt: new Date(),
platformFee: 10,
netAmount: 90,
};
sponsorService.acceptSponsorshipRequest.mockResolvedValue(mockResult);
sponsorService.acceptSponsorshipRequest.mockResolvedValue({ viewModel: mockResult } as any);
const result = await controller.acceptSponsorshipRequest(requestId, input);
const result = await controller.acceptSponsorshipRequest(requestId, input as any);
expect(result).toEqual(mockResult);
expect(sponsorService.acceptSponsorshipRequest).toHaveBeenCalledWith(requestId, input.respondedBy);
expect(sponsorService.acceptSponsorshipRequest).toHaveBeenCalledWith(
requestId,
input.respondedBy,
);
});
it('should return null on error', async () => {
const requestId = 'request-1';
const input = { respondedBy: 'user-1' };
sponsorService.acceptSponsorshipRequest.mockResolvedValue(null);
const requestId = 'r1';
const input = { respondedBy: 'u1' };
sponsorService.acceptSponsorshipRequest.mockResolvedValue({ viewModel: null } as any);
const result = await controller.acceptSponsorshipRequest(requestId, input);
const result = await controller.acceptSponsorshipRequest(requestId, input as any);
expect(result).toBeNull();
});
@@ -181,30 +205,118 @@ describe('SponsorController', () => {
describe('rejectSponsorshipRequest', () => {
it('should reject sponsorship request', async () => {
const requestId = 'request-1';
const input = { respondedBy: 'user-1', reason: 'Not interested' };
const requestId = 'r1';
const input = { respondedBy: 'u1', reason: 'Not interested' };
const mockResult = {
requestId,
status: 'rejected' as const,
rejectedAt: new Date(),
reason: 'Not interested',
};
sponsorService.rejectSponsorshipRequest.mockResolvedValue(mockResult);
sponsorService.rejectSponsorshipRequest.mockResolvedValue({ viewModel: mockResult } as any);
const result = await controller.rejectSponsorshipRequest(requestId, input);
const result = await controller.rejectSponsorshipRequest(requestId, input as any);
expect(result).toEqual(mockResult);
expect(sponsorService.rejectSponsorshipRequest).toHaveBeenCalledWith(requestId, input.respondedBy, input.reason);
expect(sponsorService.rejectSponsorshipRequest).toHaveBeenCalledWith(
requestId,
input.respondedBy,
input.reason,
);
});
it('should return null on error', async () => {
const requestId = 'request-1';
const input = { respondedBy: 'user-1' };
sponsorService.rejectSponsorshipRequest.mockResolvedValue(null);
const requestId = 'r1';
const input = { respondedBy: 'u1' };
sponsorService.rejectSponsorshipRequest.mockResolvedValue({ viewModel: null } as any);
const result = await controller.rejectSponsorshipRequest(requestId, input);
const result = await controller.rejectSponsorshipRequest(requestId, input as any);
expect(result).toBeNull();
});
});
});
describe('getSponsorBilling', () => {
it('should return sponsor billing', async () => {
const sponsorId = 's1';
const mockResult = {
paymentMethods: [],
invoices: [],
stats: {
totalSpent: 0,
pendingAmount: 0,
nextPaymentDate: '2025-01-01',
nextPaymentAmount: 0,
activeSponsorships: 0,
averageMonthlySpend: 0,
},
};
sponsorService.getSponsorBilling.mockResolvedValue({ viewModel: mockResult } as any);
const result = await controller.getSponsorBilling(sponsorId);
expect(result).toEqual(mockResult);
expect(sponsorService.getSponsorBilling).toHaveBeenCalledWith(sponsorId);
});
});
describe('getAvailableLeagues', () => {
it('should return available leagues', async () => {
const mockResult: any[] = [];
sponsorService.getAvailableLeagues.mockResolvedValue({ viewModel: mockResult } as any);
const result = await controller.getAvailableLeagues();
expect(result).toEqual(mockResult);
expect(sponsorService.getAvailableLeagues).toHaveBeenCalled();
});
});
describe('getLeagueDetail', () => {
it('should return league detail', async () => {
const leagueId = 'league-1';
const mockResult = {
league: { id: leagueId } as any,
drivers: [],
races: [],
};
sponsorService.getLeagueDetail.mockResolvedValue({ viewModel: mockResult } as any);
const result = await controller.getLeagueDetail(leagueId);
expect(result).toEqual(mockResult);
expect(sponsorService.getLeagueDetail).toHaveBeenCalledWith(leagueId);
});
});
describe('getSponsorSettings', () => {
it('should return sponsor settings', async () => {
const sponsorId = 's1';
const mockResult = {
profile: {} as any,
notifications: {} as any,
privacy: {} as any,
};
sponsorService.getSponsorSettings.mockResolvedValue({ viewModel: mockResult } as any);
const result = await controller.getSponsorSettings(sponsorId);
expect(result).toEqual(mockResult);
expect(sponsorService.getSponsorSettings).toHaveBeenCalledWith(sponsorId);
});
});
describe('updateSponsorSettings', () => {
it('should update sponsor settings', async () => {
const sponsorId = 's1';
const input = {};
const mockResult = { success: true };
sponsorService.updateSponsorSettings.mockResolvedValue({ viewModel: mockResult } as any);
const result = await controller.updateSponsorSettings(sponsorId, input);
expect(result).toEqual(mockResult);
expect(sponsorService.updateSponsorSettings).toHaveBeenCalledWith(sponsorId, input);
});
});
});

View File

@@ -23,7 +23,7 @@ import { RaceDTO } from './dtos/RaceDTO';
import { SponsorProfileDTO } from './dtos/SponsorProfileDTO';
import { NotificationSettingsDTO } from './dtos/NotificationSettingsDTO';
import { PrivacySettingsDTO } from './dtos/PrivacySettingsDTO';
import type { AcceptSponsorshipRequestResultPort } from '@core/racing/application/ports/output/AcceptSponsorshipRequestResultPort';
import type { AcceptSponsorshipRequestResultViewModel } from './presenters/AcceptSponsorshipRequestPresenter';
import type { RejectSponsorshipRequestResultDTO } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
@ApiTags('sponsors')
@@ -33,129 +33,212 @@ export class SponsorController {
@Get('pricing')
@ApiOperation({ summary: 'Get sponsorship pricing for an entity' })
@ApiResponse({ status: 200, description: 'Sponsorship pricing', type: GetEntitySponsorshipPricingResultDTO })
@ApiResponse({
status: 200,
description: 'Sponsorship pricing',
type: GetEntitySponsorshipPricingResultDTO,
})
async getEntitySponsorshipPricing(): Promise<GetEntitySponsorshipPricingResultDTO> {
return this.sponsorService.getEntitySponsorshipPricing();
const presenter = await this.sponsorService.getEntitySponsorshipPricing();
return presenter.viewModel;
}
@Get()
@ApiOperation({ summary: 'Get all sponsors' })
@ApiResponse({ status: 200, description: 'List of sponsors', type: GetSponsorsOutputDTO })
@ApiResponse({
status: 200,
description: 'List of sponsors',
type: GetSponsorsOutputDTO,
})
async getSponsors(): Promise<GetSponsorsOutputDTO> {
return this.sponsorService.getSponsors();
const presenter = await this.sponsorService.getSponsors();
return presenter.viewModel;
}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a new sponsor' })
@ApiResponse({ status: 201, description: 'Sponsor created', type: CreateSponsorOutputDTO })
@ApiResponse({
status: 201,
description: 'Sponsor created',
type: CreateSponsorOutputDTO,
})
async createSponsor(@Body() input: CreateSponsorInputDTO): Promise<CreateSponsorOutputDTO> {
return this.sponsorService.createSponsor(input);
const presenter = await this.sponsorService.createSponsor(input);
return presenter.viewModel;
}
// Add other Sponsor endpoints here based on other presenters
@Get('dashboard/:sponsorId')
@ApiOperation({ summary: 'Get sponsor dashboard metrics and sponsored leagues' })
@ApiResponse({ status: 200, description: 'Sponsor dashboard data', type: SponsorDashboardDTO })
@ApiResponse({
status: 200,
description: 'Sponsor dashboard data',
type: SponsorDashboardDTO,
})
@ApiResponse({ status: 404, description: 'Sponsor not found' })
async getSponsorDashboard(@Param('sponsorId') sponsorId: string): Promise<SponsorDashboardDTO | null> {
return this.sponsorService.getSponsorDashboard({ sponsorId } as GetSponsorDashboardQueryParamsDTO);
async getSponsorDashboard(
@Param('sponsorId') sponsorId: string,
): Promise<SponsorDashboardDTO | null> {
const presenter = await this.sponsorService.getSponsorDashboard({
sponsorId,
} as GetSponsorDashboardQueryParamsDTO);
return presenter.viewModel;
}
@Get(':sponsorId/sponsorships')
@ApiOperation({ summary: 'Get all sponsorships for a given sponsor' })
@ApiResponse({ status: 200, description: 'List of sponsorships', type: SponsorSponsorshipsDTO })
@ApiOperation({
summary: 'Get all sponsorships for a given sponsor',
})
@ApiResponse({
status: 200,
description: 'List of sponsorships',
type: SponsorSponsorshipsDTO,
})
@ApiResponse({ status: 404, description: 'Sponsor not found' })
async getSponsorSponsorships(@Param('sponsorId') sponsorId: string): Promise<SponsorSponsorshipsDTO | null> {
return this.sponsorService.getSponsorSponsorships({ sponsorId } as GetSponsorSponsorshipsQueryParamsDTO);
async getSponsorSponsorships(
@Param('sponsorId') sponsorId: string,
): Promise<SponsorSponsorshipsDTO | null> {
const presenter = await this.sponsorService.getSponsorSponsorships({
sponsorId,
} as GetSponsorSponsorshipsQueryParamsDTO);
return presenter.viewModel;
}
@Get(':sponsorId')
@ApiOperation({ summary: 'Get a sponsor by ID' })
@ApiResponse({ status: 200, description: 'Sponsor data', type: GetSponsorOutputDTO })
@ApiResponse({
status: 200,
description: 'Sponsor data',
type: GetSponsorOutputDTO,
})
@ApiResponse({ status: 404, description: 'Sponsor not found' })
async getSponsor(@Param('sponsorId') sponsorId: string): Promise<GetSponsorOutputDTO | null> {
return this.sponsorService.getSponsor(sponsorId);
const presenter = await this.sponsorService.getSponsor(sponsorId);
return presenter.viewModel;
}
@Get('requests')
@ApiOperation({ summary: 'Get pending sponsorship requests' })
@ApiResponse({ status: 200, description: 'List of pending sponsorship requests', type: GetPendingSponsorshipRequestsOutputDTO })
async getPendingSponsorshipRequests(@Query() query: { entityType: string; entityId: string }): Promise<GetPendingSponsorshipRequestsOutputDTO> {
return this.sponsorService.getPendingSponsorshipRequests(query as { entityType: import('@core/racing/domain/entities/SponsorshipRequest').SponsorableEntityType; entityId: string });
@ApiResponse({
status: 200,
description: 'List of pending sponsorship requests',
type: GetPendingSponsorshipRequestsOutputDTO,
})
async getPendingSponsorshipRequests(
@Query() query: { entityType: string; entityId: string },
): Promise<GetPendingSponsorshipRequestsOutputDTO | null> {
const presenter = await this.sponsorService.getPendingSponsorshipRequests(
query as {
entityType: import('@core/racing/domain/entities/SponsorshipRequest').SponsorableEntityType;
entityId: string;
},
);
return presenter.viewModel;
}
@Post('requests/:requestId/accept')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Accept a sponsorship request' })
@ApiResponse({ status: 200, description: 'Sponsorship request accepted' })
@ApiResponse({ status: 400, description: 'Invalid request' })
@ApiResponse({ status: 404, description: 'Request not found' })
async acceptSponsorshipRequest(@Param('requestId') requestId: string, @Body() input: AcceptSponsorshipRequestInputDTO): Promise<AcceptSponsorshipRequestResultPort | null> {
return this.sponsorService.acceptSponsorshipRequest(requestId, input.respondedBy);
}
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Accept a sponsorship request' })
@ApiResponse({ status: 200, description: 'Sponsorship request accepted' })
@ApiResponse({ status: 400, description: 'Invalid request' })
@ApiResponse({ status: 404, description: 'Request not found' })
async acceptSponsorshipRequest(
@Param('requestId') requestId: string,
@Body() input: AcceptSponsorshipRequestInputDTO,
): Promise<AcceptSponsorshipRequestResultViewModel | null> {
const presenter = await this.sponsorService.acceptSponsorshipRequest(
requestId,
input.respondedBy,
);
return presenter.viewModel;
}
@Post('requests/:requestId/reject')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Reject a sponsorship request' })
@ApiResponse({ status: 200, description: 'Sponsorship request rejected' })
@ApiResponse({ status: 400, description: 'Invalid request' })
@ApiResponse({ status: 404, description: 'Request not found' })
async rejectSponsorshipRequest(@Param('requestId') requestId: string, @Body() input: RejectSponsorshipRequestInputDTO): Promise<RejectSponsorshipRequestResultDTO | null> {
return this.sponsorService.rejectSponsorshipRequest(requestId, input.respondedBy, input.reason);
}
@Post('requests/:requestId/reject')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Reject a sponsorship request' })
@ApiResponse({ status: 200, description: 'Sponsorship request rejected' })
@ApiResponse({ status: 400, description: 'Invalid request' })
@ApiResponse({ status: 404, description: 'Request not found' })
async rejectSponsorshipRequest(
@Param('requestId') requestId: string,
@Body() input: RejectSponsorshipRequestInputDTO,
): Promise<RejectSponsorshipRequestResultDTO | null> {
const presenter = await this.sponsorService.rejectSponsorshipRequest(
requestId,
input.respondedBy,
input.reason,
);
return presenter.viewModel;
}
@Get('billing/:sponsorId')
@ApiOperation({ summary: 'Get sponsor billing information' })
@ApiResponse({ status: 200, description: 'Sponsor billing data', type: Object })
async getSponsorBilling(@Param('sponsorId') sponsorId: string): Promise<{
paymentMethods: PaymentMethodDTO[];
invoices: InvoiceDTO[];
stats: BillingStatsDTO;
}> {
return this.sponsorService.getSponsorBilling(sponsorId);
}
@Get('billing/:sponsorId')
@ApiOperation({ summary: 'Get sponsor billing information' })
@ApiResponse({ status: 200, description: 'Sponsor billing data', type: Object })
async getSponsorBilling(
@Param('sponsorId') sponsorId: string,
): Promise<{
paymentMethods: PaymentMethodDTO[];
invoices: InvoiceDTO[];
stats: BillingStatsDTO;
}> {
const presenter = await this.sponsorService.getSponsorBilling(sponsorId);
return presenter.viewModel;
}
@Get('leagues/available')
@ApiOperation({ summary: 'Get available leagues for sponsorship' })
@ApiResponse({ status: 200, description: 'Available leagues', type: [AvailableLeagueDTO] })
async getAvailableLeagues(): Promise<AvailableLeagueDTO[]> {
return this.sponsorService.getAvailableLeagues();
}
@Get('leagues/available')
@ApiOperation({ summary: 'Get available leagues for sponsorship' })
@ApiResponse({
status: 200,
description: 'Available leagues',
type: [AvailableLeagueDTO],
})
async getAvailableLeagues(): Promise<AvailableLeagueDTO[] | null> {
const presenter = await this.sponsorService.getAvailableLeagues();
return presenter.viewModel;
}
@Get('leagues/:leagueId/detail')
@ApiOperation({ summary: 'Get detailed league information for sponsors' })
@ApiResponse({ status: 200, description: 'League detail data', type: Object })
async getLeagueDetail(@Param('leagueId') leagueId: string): Promise<{
league: LeagueDetailDTO;
drivers: DriverDTO[];
races: RaceDTO[];
}> {
return this.sponsorService.getLeagueDetail(leagueId);
}
@Get('leagues/:leagueId/detail')
@ApiOperation({ summary: 'Get detailed league information for sponsors' })
@ApiResponse({ status: 200, description: 'League detail data', type: Object })
async getLeagueDetail(
@Param('leagueId') leagueId: string,
): Promise<{
league: LeagueDetailDTO;
drivers: DriverDTO[];
races: RaceDTO[];
} | null> {
const presenter = await this.sponsorService.getLeagueDetail(leagueId);
return presenter.viewModel;
}
@Get('settings/:sponsorId')
@ApiOperation({ summary: 'Get sponsor settings' })
@ApiResponse({ status: 200, description: 'Sponsor settings', type: Object })
async getSponsorSettings(@Param('sponsorId') sponsorId: string): Promise<{
profile: SponsorProfileDTO;
notifications: NotificationSettingsDTO;
privacy: PrivacySettingsDTO;
}> {
return this.sponsorService.getSponsorSettings(sponsorId);
}
@Get('settings/:sponsorId')
@ApiOperation({ summary: 'Get sponsor settings' })
@ApiResponse({ status: 200, description: 'Sponsor settings', type: Object })
async getSponsorSettings(
@Param('sponsorId') sponsorId: string,
): Promise<{
profile: SponsorProfileDTO;
notifications: NotificationSettingsDTO;
privacy: PrivacySettingsDTO;
} | null> {
const presenter = await this.sponsorService.getSponsorSettings(sponsorId);
return presenter.viewModel;
}
@Put('settings/:sponsorId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Update sponsor settings' })
@ApiResponse({ status: 200, description: 'Settings updated successfully' })
async updateSponsorSettings(
@Param('sponsorId') sponsorId: string,
@Body() input: {
profile?: Partial<SponsorProfileDTO>;
notifications?: Partial<NotificationSettingsDTO>;
privacy?: Partial<PrivacySettingsDTO>;
}
): Promise<void> {
return this.sponsorService.updateSponsorSettings(sponsorId, input);
}
@Put('settings/:sponsorId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Update sponsor settings' })
@ApiResponse({ status: 200, description: 'Settings updated successfully' })
async updateSponsorSettings(
@Param('sponsorId') sponsorId: string,
@Body()
input: {
profile?: Partial<SponsorProfileDTO>;
notifications?: Partial<NotificationSettingsDTO>;
privacy?: Partial<PrivacySettingsDTO>;
},
): Promise<{ success: boolean; errorCode?: string; message?: string } | null> {
const presenter = await this.sponsorService.updateSponsorSettings(sponsorId, input);
return presenter.viewModel;
}
}

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { SponsorService } from './SponsorService';
import { Result } from '@core/shared/application/Result';
import type { Logger } from '@core/shared/application';
import type { GetSponsorshipPricingUseCase } from '@core/racing/application/use-cases/GetSponsorshipPricingUseCase';
import type { GetSponsorsUseCase } from '@core/racing/application/use-cases/GetSponsorsUseCase';
import type { CreateSponsorUseCase } from '@core/racing/application/use-cases/CreateSponsorUseCase';
@@ -9,8 +10,7 @@ import type { GetSponsorUseCase } from '@core/racing/application/use-cases/GetSp
import type { GetPendingSponsorshipRequestsUseCase } from '@core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
import type { AcceptSponsorshipRequestUseCase } from '@core/racing/application/use-cases/AcceptSponsorshipRequestUseCase';
import type { RejectSponsorshipRequestUseCase } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import { SponsorService } from './SponsorService';
describe('SponsorService', () => {
let service: SponsorService;
@@ -23,12 +23,7 @@ describe('SponsorService', () => {
let getPendingSponsorshipRequestsUseCase: { execute: Mock };
let acceptSponsorshipRequestUseCase: { execute: Mock };
let rejectSponsorshipRequestUseCase: { execute: Mock };
let logger: {
debug: Mock;
info: Mock;
warn: Mock;
error: Mock;
};
let logger: Logger;
beforeEach(() => {
getSponsorshipPricingUseCase = { execute: vi.fn() };
@@ -45,7 +40,7 @@ describe('SponsorService', () => {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
} as unknown as Logger;
service = new SponsorService(
getSponsorshipPricingUseCase as unknown as GetSponsorshipPricingUseCase,
@@ -57,136 +52,199 @@ describe('SponsorService', () => {
getPendingSponsorshipRequestsUseCase as unknown as GetPendingSponsorshipRequestsUseCase,
acceptSponsorshipRequestUseCase as unknown as AcceptSponsorshipRequestUseCase,
rejectSponsorshipRequestUseCase as unknown as RejectSponsorshipRequestUseCase,
logger as unknown as Logger,
logger,
);
});
describe('getEntitySponsorshipPricing', () => {
it('should return sponsorship pricing', async () => {
const mockPresenter = {
viewModel: { entityType: 'season', entityId: 'season-1', pricing: [] },
it('returns presenter with pricing data on success', async () => {
const outputPort = {
entityType: 'season',
entityId: 'season-1',
pricing: [
{ id: 'tier-gold', level: 'Gold', price: 500, currency: 'USD' },
],
};
getSponsorshipPricingUseCase.execute.mockResolvedValue(undefined);
getSponsorshipPricingUseCase.execute.mockResolvedValue(Result.ok(outputPort));
// Mock the presenter
const originalGetSponsorshipPricingPresenter = await import('./presenters/GetSponsorshipPricingPresenter');
const mockPresenterClass = vi.fn().mockImplementation(() => mockPresenter);
vi.doMock('./presenters/GetSponsorshipPricingPresenter', () => ({
GetSponsorshipPricingPresenter: mockPresenterClass,
}));
const presenter = await service.getEntitySponsorshipPricing();
const result = await service.getEntitySponsorshipPricing();
expect(presenter.viewModel).toEqual({
entityType: 'season',
entityId: 'season-1',
pricing: [
{ id: 'tier-gold', level: 'Gold', price: 500, currency: 'USD' },
],
});
});
expect(result).toEqual(mockPresenter.viewModel);
expect(getSponsorshipPricingUseCase.execute).toHaveBeenCalledWith(undefined, mockPresenter);
it('returns empty pricing on error', async () => {
getSponsorshipPricingUseCase.execute.mockResolvedValue(Result.err({ code: 'REPOSITORY_ERROR' }));
const presenter = await service.getEntitySponsorshipPricing();
expect(presenter.viewModel).toEqual({
entityType: 'season',
entityId: '',
pricing: [],
});
});
});
describe('getSponsors', () => {
it('should return sponsors list', async () => {
const mockPresenter = {
viewModel: { sponsors: [] },
};
getSponsorsUseCase.execute.mockResolvedValue(undefined);
it('returns sponsors in presenter on success', async () => {
const outputPort = { sponsors: [{ id: 's1', name: 'S1', contactEmail: 's1@test', createdAt: new Date() }] };
getSponsorsUseCase.execute.mockResolvedValue(Result.ok(outputPort));
const result = await service.getSponsors();
const presenter = await service.getSponsors();
expect(result).toEqual(mockPresenter.viewModel);
expect(getSponsorsUseCase.execute).toHaveBeenCalledWith(undefined, expect.any(Object));
expect(presenter.viewModel).toEqual({ sponsors: outputPort.sponsors });
});
it('returns empty list on error', async () => {
getSponsorsUseCase.execute.mockResolvedValue(Result.err({ code: 'REPOSITORY_ERROR' }));
const presenter = await service.getSponsors();
expect(presenter.viewModel).toEqual({ sponsors: [] });
});
});
describe('createSponsor', () => {
it('should create sponsor successfully', async () => {
const input = { name: 'Test Sponsor', contactEmail: 'test@example.com' };
const mockPresenter = {
viewModel: { id: 'sponsor-1', name: 'Test Sponsor' },
it('returns created sponsor in presenter on success', async () => {
const input = { name: 'Test', contactEmail: 'test@example.com' };
const outputPort = {
sponsor: {
id: 's1',
name: 'Test',
contactEmail: 'test@example.com',
createdAt: new Date(),
},
};
createSponsorUseCase.execute.mockResolvedValue(undefined);
createSponsorUseCase.execute.mockResolvedValue(Result.ok(outputPort));
const result = await service.createSponsor(input);
const presenter = await service.createSponsor(input as any);
expect(result).toEqual(mockPresenter.viewModel);
expect(createSponsorUseCase.execute).toHaveBeenCalledWith(input, expect.any(Object));
expect(presenter.viewModel).toEqual({ sponsor: outputPort.sponsor });
});
it('throws on error', async () => {
const input = { name: 'Test', contactEmail: 'test@example.com' };
createSponsorUseCase.execute.mockResolvedValue(
Result.err({ code: 'VALIDATION_ERROR', details: { message: 'Invalid' } }),
);
await expect(service.createSponsor(input as any)).rejects.toThrow('Invalid');
});
});
describe('getSponsorDashboard', () => {
it('should return sponsor dashboard', async () => {
const params = { sponsorId: 'sponsor-1' };
const mockPresenter = {
viewModel: { sponsorId: 'sponsor-1', metrics: {}, sponsoredLeagues: [] },
it('returns dashboard in presenter on success', async () => {
const params = { sponsorId: 's1' };
const outputPort = {
sponsorId: 's1',
sponsorName: 'S1',
metrics: {} as any,
sponsoredLeagues: [],
investment: {} as any,
};
getSponsorDashboardUseCase.execute.mockResolvedValue(undefined);
getSponsorDashboardUseCase.execute.mockResolvedValue(Result.ok(outputPort));
const result = await service.getSponsorDashboard(params);
const presenter = await service.getSponsorDashboard(params as any);
expect(result).toEqual(mockPresenter.viewModel);
expect(getSponsorDashboardUseCase.execute).toHaveBeenCalledWith(params, expect.any(Object));
expect(presenter.viewModel).toEqual(outputPort);
});
it('returns null in presenter on error', async () => {
const params = { sponsorId: 's1' };
getSponsorDashboardUseCase.execute.mockResolvedValue(Result.err({ code: 'REPOSITORY_ERROR' }));
const presenter = await service.getSponsorDashboard(params as any);
expect(presenter.viewModel).toBeNull();
});
});
describe('getSponsorSponsorships', () => {
it('should return sponsor sponsorships', async () => {
const params = { sponsorId: 'sponsor-1' };
const mockPresenter = {
viewModel: { sponsorId: 'sponsor-1', sponsorships: [] },
it('returns sponsorships in presenter on success', async () => {
const params = { sponsorId: 's1' };
const outputPort = {
sponsorId: 's1',
sponsorName: 'S1',
sponsorships: [],
summary: {
totalSponsorships: 0,
activeSponsorships: 0,
totalInvestment: 0,
totalPlatformFees: 0,
currency: 'USD',
},
};
getSponsorSponsorshipsUseCase.execute.mockResolvedValue(undefined);
getSponsorSponsorshipsUseCase.execute.mockResolvedValue(Result.ok(outputPort));
const result = await service.getSponsorSponsorships(params);
const presenter = await service.getSponsorSponsorships(params as any);
expect(result).toEqual(mockPresenter.viewModel);
expect(getSponsorSponsorshipsUseCase.execute).toHaveBeenCalledWith(params, expect.any(Object));
expect(presenter.viewModel).toEqual(outputPort);
});
it('returns null in presenter on error', async () => {
const params = { sponsorId: 's1' };
getSponsorSponsorshipsUseCase.execute.mockResolvedValue(
Result.err({ code: 'REPOSITORY_ERROR' }),
);
const presenter = await service.getSponsorSponsorships(params as any);
expect(presenter.viewModel).toBeNull();
});
});
describe('getSponsor', () => {
it('should return sponsor when found', async () => {
const sponsorId = 'sponsor-1';
const mockSponsor = { id: sponsorId, name: 'Test Sponsor' };
getSponsorUseCase.execute.mockResolvedValue(Result.ok(mockSponsor));
it('returns sponsor in presenter when found', async () => {
const sponsorId = 's1';
const output = { sponsor: { id: sponsorId, name: 'S1' } };
getSponsorUseCase.execute.mockResolvedValue(Result.ok(output));
const result = await service.getSponsor(sponsorId);
const presenter = await service.getSponsor(sponsorId);
expect(result).toEqual(mockSponsor);
expect(getSponsorUseCase.execute).toHaveBeenCalledWith({ sponsorId });
expect(presenter.viewModel).toEqual({ sponsor: output.sponsor });
});
it('should return null when sponsor not found', async () => {
const sponsorId = 'sponsor-1';
getSponsorUseCase.execute.mockResolvedValue(Result.err({ code: 'NOT_FOUND' }));
it('returns null in presenter when not found', async () => {
const sponsorId = 's1';
getSponsorUseCase.execute.mockResolvedValue(Result.ok(null));
const result = await service.getSponsor(sponsorId);
const presenter = await service.getSponsor(sponsorId);
expect(result).toBeNull();
expect(presenter.viewModel).toBeNull();
});
});
describe('getPendingSponsorshipRequests', () => {
it('should return pending sponsorship requests', async () => {
it('returns requests in presenter on success', async () => {
const params = { entityType: 'season' as const, entityId: 'season-1' };
const mockResult = {
const outputPort = {
entityType: 'season',
entityId: 'season-1',
requests: [],
totalCount: 0,
};
getPendingSponsorshipRequestsUseCase.execute.mockResolvedValue(Result.ok(mockResult));
getPendingSponsorshipRequestsUseCase.execute.mockResolvedValue(Result.ok(outputPort));
const result = await service.getPendingSponsorshipRequests(params);
const presenter = await service.getPendingSponsorshipRequests(params);
expect(result).toEqual(mockResult);
expect(getPendingSponsorshipRequestsUseCase.execute).toHaveBeenCalledWith(params);
expect(presenter.viewModel).toEqual(outputPort);
});
it('should return empty result on error', async () => {
it('returns empty result on error', async () => {
const params = { entityType: 'season' as const, entityId: 'season-1' };
getPendingSponsorshipRequestsUseCase.execute.mockResolvedValue(Result.err({ code: 'REPOSITORY_ERROR' }));
getPendingSponsorshipRequestsUseCase.execute.mockResolvedValue(
Result.err({ code: 'REPOSITORY_ERROR' }),
);
const result = await service.getPendingSponsorshipRequests(params);
const presenter = await service.getPendingSponsorshipRequests(params);
expect(result).toEqual({
expect(presenter.viewModel).toEqual({
entityType: 'season',
entityId: 'season-1',
requests: [],
@@ -196,63 +254,113 @@ describe('SponsorService', () => {
});
describe('acceptSponsorshipRequest', () => {
it('should accept sponsorship request successfully', async () => {
const requestId = 'request-1';
const respondedBy = 'user-1';
const mockResult = {
it('returns accept result in presenter on success', async () => {
const requestId = 'r1';
const respondedBy = 'u1';
const outputPort = {
requestId,
sponsorshipId: 'sponsorship-1',
sponsorshipId: 'sp1',
status: 'accepted' as const,
acceptedAt: new Date(),
platformFee: 10,
netAmount: 90,
};
acceptSponsorshipRequestUseCase.execute.mockResolvedValue(Result.ok(mockResult));
acceptSponsorshipRequestUseCase.execute.mockResolvedValue(Result.ok(outputPort));
const result = await service.acceptSponsorshipRequest(requestId, respondedBy);
const presenter = await service.acceptSponsorshipRequest(requestId, respondedBy);
expect(result).toEqual(mockResult);
expect(acceptSponsorshipRequestUseCase.execute).toHaveBeenCalledWith({ requestId, respondedBy });
expect(presenter.viewModel).toEqual(outputPort);
});
it('should return null on error', async () => {
const requestId = 'request-1';
const respondedBy = 'user-1';
acceptSponsorshipRequestUseCase.execute.mockResolvedValue(Result.err({ code: 'NOT_FOUND' }));
it('returns null in presenter on error', async () => {
const requestId = 'r1';
const respondedBy = 'u1';
acceptSponsorshipRequestUseCase.execute.mockResolvedValue(
Result.err({ code: 'SPONSORSHIP_REQUEST_NOT_FOUND' }),
);
const result = await service.acceptSponsorshipRequest(requestId, respondedBy);
const presenter = await service.acceptSponsorshipRequest(requestId, respondedBy);
expect(result).toBeNull();
expect(presenter.viewModel).toBeNull();
});
});
describe('rejectSponsorshipRequest', () => {
it('should reject sponsorship request successfully', async () => {
const requestId = 'request-1';
const respondedBy = 'user-1';
it('returns reject result in presenter on success', async () => {
const requestId = 'r1';
const respondedBy = 'u1';
const reason = 'Not interested';
const mockResult = {
const output = {
requestId,
status: 'rejected' as const,
rejectedAt: new Date(),
reason,
};
rejectSponsorshipRequestUseCase.execute.mockResolvedValue(Result.ok(mockResult));
rejectSponsorshipRequestUseCase.execute.mockResolvedValue(Result.ok(output));
const result = await service.rejectSponsorshipRequest(requestId, respondedBy, reason);
const presenter = await service.rejectSponsorshipRequest(requestId, respondedBy, reason);
expect(result).toEqual(mockResult);
expect(rejectSponsorshipRequestUseCase.execute).toHaveBeenCalledWith({ requestId, respondedBy, reason });
expect(presenter.viewModel).toEqual(output);
});
it('should return null on error', async () => {
const requestId = 'request-1';
const respondedBy = 'user-1';
rejectSponsorshipRequestUseCase.execute.mockResolvedValue(Result.err({ code: 'NOT_FOUND' }));
it('returns null in presenter on error', async () => {
const requestId = 'r1';
const respondedBy = 'u1';
rejectSponsorshipRequestUseCase.execute.mockResolvedValue(
Result.err({ code: 'SPONSORSHIP_REQUEST_NOT_FOUND' }),
);
const result = await service.rejectSponsorshipRequest(requestId, respondedBy);
const presenter = await service.rejectSponsorshipRequest(requestId, respondedBy);
expect(result).toBeNull();
expect(presenter.viewModel).toBeNull();
});
});
});
describe('getSponsorBilling', () => {
it('returns mock billing data in presenter', async () => {
const presenter = await service.getSponsorBilling('s1');
expect(presenter.viewModel).not.toBeNull();
expect(presenter.viewModel?.paymentMethods).toBeInstanceOf(Array);
expect(presenter.viewModel?.invoices).toBeInstanceOf(Array);
expect(presenter.viewModel?.stats).toBeDefined();
});
});
describe('getAvailableLeagues', () => {
it('returns mock leagues in presenter', async () => {
const presenter = await service.getAvailableLeagues();
expect(presenter.viewModel).not.toBeNull();
expect(presenter.viewModel?.length).toBeGreaterThan(0);
});
});
describe('getLeagueDetail', () => {
it('returns league detail in presenter', async () => {
const presenter = await service.getLeagueDetail('league-1');
expect(presenter.viewModel).not.toBeNull();
expect(presenter.viewModel?.league.id).toBe('league-1');
});
});
describe('getSponsorSettings', () => {
it('returns settings in presenter', async () => {
const presenter = await service.getSponsorSettings('s1');
expect(presenter.viewModel).not.toBeNull();
expect(presenter.viewModel?.profile).toBeDefined();
expect(presenter.viewModel?.notifications).toBeDefined();
expect(presenter.viewModel?.privacy).toBeDefined();
});
});
describe('updateSponsorSettings', () => {
it('returns success result in presenter', async () => {
const presenter = await service.updateSponsorSettings('s1', {});
expect(presenter.viewModel).toEqual({ success: true });
});
});
});

View File

@@ -1,14 +1,7 @@
import { Injectable, Inject } from '@nestjs/common';
import { GetEntitySponsorshipPricingResultDTO } from './dtos/GetEntitySponsorshipPricingResultDTO';
import { GetSponsorsOutputDTO } from './dtos/GetSponsorsOutputDTO';
import { CreateSponsorInputDTO } from './dtos/CreateSponsorInputDTO';
import { CreateSponsorOutputDTO } from './dtos/CreateSponsorOutputDTO';
import { GetSponsorDashboardQueryParamsDTO } from './dtos/GetSponsorDashboardQueryParamsDTO';
import { SponsorDashboardDTO } from './dtos/SponsorDashboardDTO';
import { GetSponsorSponsorshipsQueryParamsDTO } from './dtos/GetSponsorSponsorshipsQueryParamsDTO';
import { SponsorSponsorshipsDTO } from './dtos/SponsorSponsorshipsDTO';
import { GetSponsorOutputDTO } from './dtos/GetSponsorOutputDTO';
import { GetPendingSponsorshipRequestsOutputDTO } from './dtos/GetPendingSponsorshipRequestsOutputDTO';
import { AcceptSponsorshipRequestInputDTO } from './dtos/AcceptSponsorshipRequestInputDTO';
import { RejectSponsorshipRequestInputDTO } from './dtos/RejectSponsorshipRequestInputDTO';
import { PaymentMethodDTO } from './dtos/PaymentMethodDTO';
@@ -29,139 +22,253 @@ import { CreateSponsorUseCase } from '@core/racing/application/use-cases/CreateS
import { GetSponsorDashboardUseCase } from '@core/racing/application/use-cases/GetSponsorDashboardUseCase';
import { GetSponsorSponsorshipsUseCase } from '@core/racing/application/use-cases/GetSponsorSponsorshipsUseCase';
import { GetSponsorUseCase } from '@core/racing/application/use-cases/GetSponsorUseCase';
import { GetPendingSponsorshipRequestsUseCase, GetPendingSponsorshipRequestsDTO } from '@core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
import {
GetPendingSponsorshipRequestsUseCase,
GetPendingSponsorshipRequestsDTO,
} from '@core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
import { AcceptSponsorshipRequestUseCase } from '@core/racing/application/use-cases/AcceptSponsorshipRequestUseCase';
import { RejectSponsorshipRequestUseCase } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
import type { SponsorableEntityType } from '@core/racing/domain/entities/SponsorshipRequest';
import type { AcceptSponsorshipRequestResultPort } from '@core/racing/application/ports/output/AcceptSponsorshipRequestResultPort';
import type { RejectSponsorshipRequestResultDTO } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
import type { Logger } from '@core/shared/application';
// Presenters
import { GetEntitySponsorshipPricingPresenter } from './presenters/GetEntitySponsorshipPricingPresenter';
import { GetSponsorsPresenter } from './presenters/GetSponsorsPresenter';
import { CreateSponsorPresenter } from './presenters/CreateSponsorPresenter';
import { GetSponsorDashboardPresenter } from './presenters/GetSponsorDashboardPresenter';
import { GetSponsorSponsorshipsPresenter } from './presenters/GetSponsorSponsorshipsPresenter';
import { GetSponsorPresenter } from './presenters/GetSponsorPresenter';
import { GetPendingSponsorshipRequestsPresenter } from './presenters/GetPendingSponsorshipRequestsPresenter';
import { AcceptSponsorshipRequestPresenter } from './presenters/AcceptSponsorshipRequestPresenter';
import { RejectSponsorshipRequestPresenter } from './presenters/RejectSponsorshipRequestPresenter';
import { SponsorBillingPresenter } from './presenters/SponsorBillingPresenter';
import { AvailableLeaguesPresenter } from './presenters/AvailableLeaguesPresenter';
import { LeagueDetailPresenter } from './presenters/LeagueDetailPresenter';
import { SponsorSettingsPresenter } from './presenters/SponsorSettingsPresenter';
import { SponsorSettingsUpdatePresenter } from './presenters/SponsorSettingsUpdatePresenter';
// Tokens
import { GET_SPONSORSHIP_PRICING_USE_CASE_TOKEN, GET_SPONSORS_USE_CASE_TOKEN, CREATE_SPONSOR_USE_CASE_TOKEN, GET_SPONSOR_DASHBOARD_USE_CASE_TOKEN, GET_SPONSOR_SPONSORSHIPS_USE_CASE_TOKEN, GET_SPONSOR_USE_CASE_TOKEN, GET_PENDING_SPONSORSHIP_REQUESTS_USE_CASE_TOKEN, ACCEPT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN, REJECT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN, LOGGER_TOKEN } from './SponsorProviders';
import type { Logger } from '@core/shared/application';
import {
GET_SPONSORSHIP_PRICING_USE_CASE_TOKEN,
GET_SPONSORS_USE_CASE_TOKEN,
CREATE_SPONSOR_USE_CASE_TOKEN,
GET_SPONSOR_DASHBOARD_USE_CASE_TOKEN,
GET_SPONSOR_SPONSORSHIPS_USE_CASE_TOKEN,
GET_SPONSOR_USE_CASE_TOKEN,
GET_PENDING_SPONSORSHIP_REQUESTS_USE_CASE_TOKEN,
ACCEPT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN,
REJECT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN,
LOGGER_TOKEN,
} from './SponsorProviders';
@Injectable()
export class SponsorService {
constructor(
@Inject(GET_SPONSORSHIP_PRICING_USE_CASE_TOKEN) private readonly getSponsorshipPricingUseCase: GetSponsorshipPricingUseCase,
@Inject(GET_SPONSORS_USE_CASE_TOKEN) private readonly getSponsorsUseCase: GetSponsorsUseCase,
@Inject(CREATE_SPONSOR_USE_CASE_TOKEN) private readonly createSponsorUseCase: CreateSponsorUseCase,
@Inject(GET_SPONSOR_DASHBOARD_USE_CASE_TOKEN) private readonly getSponsorDashboardUseCase: GetSponsorDashboardUseCase,
@Inject(GET_SPONSOR_SPONSORSHIPS_USE_CASE_TOKEN) private readonly getSponsorSponsorshipsUseCase: GetSponsorSponsorshipsUseCase,
@Inject(GET_SPONSOR_USE_CASE_TOKEN) private readonly getSponsorUseCase: GetSponsorUseCase,
@Inject(GET_PENDING_SPONSORSHIP_REQUESTS_USE_CASE_TOKEN) private readonly getPendingSponsorshipRequestsUseCase: GetPendingSponsorshipRequestsUseCase,
@Inject(ACCEPT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN) private readonly acceptSponsorshipRequestUseCase: AcceptSponsorshipRequestUseCase,
@Inject(REJECT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN) private readonly rejectSponsorshipRequestUseCase: RejectSponsorshipRequestUseCase,
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
@Inject(GET_SPONSORSHIP_PRICING_USE_CASE_TOKEN)
private readonly getSponsorshipPricingUseCase: GetSponsorshipPricingUseCase,
@Inject(GET_SPONSORS_USE_CASE_TOKEN)
private readonly getSponsorsUseCase: GetSponsorsUseCase,
@Inject(CREATE_SPONSOR_USE_CASE_TOKEN)
private readonly createSponsorUseCase: CreateSponsorUseCase,
@Inject(GET_SPONSOR_DASHBOARD_USE_CASE_TOKEN)
private readonly getSponsorDashboardUseCase: GetSponsorDashboardUseCase,
@Inject(GET_SPONSOR_SPONSORSHIPS_USE_CASE_TOKEN)
private readonly getSponsorSponsorshipsUseCase: GetSponsorSponsorshipsUseCase,
@Inject(GET_SPONSOR_USE_CASE_TOKEN)
private readonly getSponsorUseCase: GetSponsorUseCase,
@Inject(GET_PENDING_SPONSORSHIP_REQUESTS_USE_CASE_TOKEN)
private readonly getPendingSponsorshipRequestsUseCase: GetPendingSponsorshipRequestsUseCase,
@Inject(ACCEPT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN)
private readonly acceptSponsorshipRequestUseCase: AcceptSponsorshipRequestUseCase,
@Inject(REJECT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN)
private readonly rejectSponsorshipRequestUseCase: RejectSponsorshipRequestUseCase,
@Inject(LOGGER_TOKEN)
private readonly logger: Logger,
) {}
async getEntitySponsorshipPricing(): Promise<GetEntitySponsorshipPricingResultDTO> {
async getEntitySponsorshipPricing(): Promise<GetEntitySponsorshipPricingPresenter> {
this.logger.debug('[SponsorService] Fetching sponsorship pricing.');
const presenter = new GetEntitySponsorshipPricingPresenter();
const result = await this.getSponsorshipPricingUseCase.execute();
if (result.isErr()) {
this.logger.error('[SponsorService] Failed to fetch sponsorship pricing.', result.error);
return { entityType: 'season', entityId: '', pricing: [] };
presenter.present(null);
return presenter;
}
return result.value as GetEntitySponsorshipPricingResultDTO;
presenter.present(result.value);
return presenter;
}
async getSponsors(): Promise<GetSponsorsOutputDTO> {
async getSponsors(): Promise<GetSponsorsPresenter> {
this.logger.debug('[SponsorService] Fetching sponsors.');
const presenter = new GetSponsorsPresenter();
const result = await this.getSponsorsUseCase.execute();
if (result.isErr()) {
this.logger.error('[SponsorService] Failed to fetch sponsors.', result.error);
return { sponsors: [] };
presenter.present({ sponsors: [] });
return presenter;
}
return result.value as GetSponsorsOutputDTO;
presenter.present(result.value);
return presenter;
}
async createSponsor(input: CreateSponsorInputDTO): Promise<CreateSponsorOutputDTO> {
async createSponsor(input: CreateSponsorInputDTO): Promise<CreateSponsorPresenter> {
this.logger.debug('[SponsorService] Creating sponsor.', { input });
const presenter = new CreateSponsorPresenter();
const result = await this.createSponsorUseCase.execute(input);
if (result.isErr()) {
this.logger.error('[SponsorService] Failed to create sponsor.', result.error);
throw new Error(result.error.details?.message || 'Failed to create sponsor');
}
return result.value as CreateSponsorOutputDTO;
presenter.present(result.value);
return presenter;
}
async getSponsorDashboard(params: GetSponsorDashboardQueryParamsDTO): Promise<SponsorDashboardDTO | null> {
async getSponsorDashboard(
params: GetSponsorDashboardQueryParamsDTO,
): Promise<GetSponsorDashboardPresenter> {
this.logger.debug('[SponsorService] Fetching sponsor dashboard.', { params });
const presenter = new GetSponsorDashboardPresenter();
const result = await this.getSponsorDashboardUseCase.execute(params);
if (result.isErr()) {
this.logger.error('[SponsorService] Failed to fetch sponsor dashboard.', result.error);
return null;
presenter.present(null);
return presenter;
}
return result.value as SponsorDashboardDTO | null;
presenter.present(result.value);
return presenter;
}
async getSponsorSponsorships(params: GetSponsorSponsorshipsQueryParamsDTO): Promise<SponsorSponsorshipsDTO | null> {
async getSponsorSponsorships(
params: GetSponsorSponsorshipsQueryParamsDTO,
): Promise<GetSponsorSponsorshipsPresenter> {
this.logger.debug('[SponsorService] Fetching sponsor sponsorships.', { params });
const presenter = new GetSponsorSponsorshipsPresenter();
const result = await this.getSponsorSponsorshipsUseCase.execute(params);
if (result.isErr()) {
this.logger.error('[SponsorService] Failed to fetch sponsor sponsorships.', result.error);
return null;
presenter.present(null);
return presenter;
}
return result.value as SponsorSponsorshipsDTO | null;
presenter.present(result.value);
return presenter;
}
async getSponsor(sponsorId: string): Promise<GetSponsorOutputDTO | null> {
async getSponsor(sponsorId: string): Promise<GetSponsorPresenter> {
this.logger.debug('[SponsorService] Fetching sponsor.', { sponsorId });
const presenter = new GetSponsorPresenter();
const result = await this.getSponsorUseCase.execute({ sponsorId });
if (result.isErr()) {
this.logger.error('[SponsorService] Failed to fetch sponsor.', result.error);
return null;
presenter.present(null);
return presenter;
}
return result.value as GetSponsorOutputDTO | null;
presenter.present(result.value);
return presenter;
}
async getPendingSponsorshipRequests(params: { entityType: SponsorableEntityType; entityId: string }): Promise<GetPendingSponsorshipRequestsOutputDTO> {
async getPendingSponsorshipRequests(params: {
entityType: SponsorableEntityType;
entityId: string;
}): Promise<GetPendingSponsorshipRequestsPresenter> {
this.logger.debug('[SponsorService] Fetching pending sponsorship requests.', { params });
const result = await this.getPendingSponsorshipRequestsUseCase.execute(params as GetPendingSponsorshipRequestsDTO);
const presenter = new GetPendingSponsorshipRequestsPresenter();
const result = await this.getPendingSponsorshipRequestsUseCase.execute(
params as GetPendingSponsorshipRequestsDTO,
);
if (result.isErr()) {
this.logger.error('[SponsorService] Failed to fetch pending sponsorship requests.', result.error);
return { entityType: params.entityType, entityId: params.entityId, requests: [], totalCount: 0 };
presenter.present({
entityType: params.entityType,
entityId: params.entityId,
requests: [],
totalCount: 0,
});
return presenter;
}
return result.value as GetPendingSponsorshipRequestsOutputDTO;
presenter.present(result.value);
return presenter;
}
async acceptSponsorshipRequest(requestId: string, respondedBy: string): Promise<AcceptSponsorshipRequestResultPort | null> {
this.logger.debug('[SponsorService] Accepting sponsorship request.', { requestId, respondedBy });
async acceptSponsorshipRequest(
requestId: string,
respondedBy: string,
): Promise<AcceptSponsorshipRequestPresenter> {
this.logger.debug('[SponsorService] Accepting sponsorship request.', {
requestId,
respondedBy,
});
const presenter = new AcceptSponsorshipRequestPresenter();
const result = await this.acceptSponsorshipRequestUseCase.execute({
requestId,
respondedBy,
} as AcceptSponsorshipRequestInputDTO);
const result = await this.acceptSponsorshipRequestUseCase.execute({ requestId, respondedBy });
if (result.isErr()) {
this.logger.error('[SponsorService] Failed to accept sponsorship request.', result.error);
return null;
presenter.present(null);
return presenter;
}
return result.value;
presenter.present(result.value);
return presenter;
}
async rejectSponsorshipRequest(requestId: string, respondedBy: string, reason?: string): Promise<RejectSponsorshipRequestResultDTO | null> {
this.logger.debug('[SponsorService] Rejecting sponsorship request.', { requestId, respondedBy, reason });
async rejectSponsorshipRequest(
requestId: string,
respondedBy: string,
reason?: string,
): Promise<RejectSponsorshipRequestPresenter> {
this.logger.debug('[SponsorService] Rejecting sponsorship request.', {
requestId,
respondedBy,
reason,
});
const presenter = new RejectSponsorshipRequestPresenter();
const result = await this.rejectSponsorshipRequestUseCase.execute({
requestId,
respondedBy,
reason,
} as RejectSponsorshipRequestInputDTO);
const result = await this.rejectSponsorshipRequestUseCase.execute({ requestId, respondedBy, reason });
if (result.isErr()) {
this.logger.error('[SponsorService] Failed to reject sponsorship request.', result.error);
return null;
presenter.present(null);
return presenter;
}
return result.value;
presenter.present(result.value);
return presenter;
}
async getSponsorBilling(sponsorId: string): Promise<{
paymentMethods: PaymentMethodDTO[];
invoices: InvoiceDTO[];
stats: BillingStatsDTO;
}> {
async getSponsorBilling(sponsorId: string): Promise<SponsorBillingPresenter> {
this.logger.debug('[SponsorService] Fetching sponsor billing.', { sponsorId });
const presenter = new SponsorBillingPresenter();
// Mock data - in real implementation, this would come from repositories
const paymentMethods: PaymentMethodDTO[] = [
{
@@ -242,14 +349,16 @@ export class SponsorService {
averageMonthlySpend: 2075,
};
return { paymentMethods, invoices, stats };
presenter.present({ paymentMethods, invoices, stats });
return presenter;
}
async getAvailableLeagues(): Promise<AvailableLeagueDTO[]> {
async getAvailableLeagues(): Promise<AvailableLeaguesPresenter> {
this.logger.debug('[SponsorService] Fetching available leagues.');
// Mock data
return [
const presenter = new AvailableLeaguesPresenter();
const leagues: AvailableLeagueDTO[] = [
{
id: 'league-1',
name: 'GT3 Masters Championship',
@@ -262,7 +371,8 @@ export class SponsorService {
tier: 'premium',
nextRace: '2025-12-20',
seasonStatus: 'active',
description: 'Premier GT3 racing with top-tier drivers. Weekly broadcasts and active community.',
description:
'Premier GT3 racing with top-tier drivers. Weekly broadcasts and active community.',
},
{
id: 'league-2',
@@ -276,18 +386,20 @@ export class SponsorService {
tier: 'premium',
nextRace: '2026-01-05',
seasonStatus: 'active',
description: 'Multi-class endurance racing. High engagement from dedicated endurance fans.',
description:
'Multi-class endurance racing. High engagement from dedicated endurance fans.',
},
];
presenter.present(leagues);
return presenter;
}
async getLeagueDetail(leagueId: string): Promise<{
league: LeagueDetailDTO;
drivers: DriverDTO[];
races: RaceDTO[];
}> {
async getLeagueDetail(leagueId: string): Promise<LeagueDetailPresenter> {
this.logger.debug('[SponsorService] Fetching league detail.', { leagueId });
const presenter = new LeagueDetailPresenter();
// Mock data
const league: LeagueDetailDTO = {
id: leagueId,
@@ -295,7 +407,8 @@ export class SponsorService {
game: 'iRacing',
tier: 'premium',
season: 'Season 3',
description: 'Premier GT3 racing with top-tier drivers competing across the world\'s most iconic circuits.',
description:
"Premier GT3 racing with top-tier drivers competing across the world's most iconic circuits.",
drivers: 48,
races: 12,
completedRaces: 8,
@@ -316,7 +429,7 @@ export class SponsorService {
'Race results page branding',
'Social media feature posts',
'Newsletter sponsor spot',
]
],
},
secondary: {
available: 1,
@@ -327,31 +440,58 @@ export class SponsorService {
'League page sidebar placement',
'Race results mention',
'Social media mentions',
]
],
},
},
};
const drivers: DriverDTO[] = [
{ id: 'd1', name: 'Max Verstappen', country: 'NL', position: 1, races: 8, impressions: 4200, team: 'Red Bull Racing' },
{ id: 'd2', name: 'Lewis Hamilton', country: 'GB', position: 2, races: 8, impressions: 3980, team: 'Mercedes AMG' },
{
id: 'd1',
name: 'Max Verstappen',
country: 'NL',
position: 1,
races: 8,
impressions: 4200,
team: 'Red Bull Racing',
},
{
id: 'd2',
name: 'Lewis Hamilton',
country: 'GB',
position: 2,
races: 8,
impressions: 3980,
team: 'Mercedes AMG',
},
];
const races: RaceDTO[] = [
{ id: 'r1', name: 'Spa-Francorchamps', date: '2025-12-20', views: 0, status: 'upcoming' },
{ id: 'r2', name: 'Monza', date: '2025-12-08', views: 5800, status: 'completed' },
{
id: 'r1',
name: 'Spa-Francorchamps',
date: '2025-12-20',
views: 0,
status: 'upcoming',
},
{
id: 'r2',
name: 'Monza',
date: '2025-12-08',
views: 5800,
status: 'completed',
},
];
return { league, drivers, races };
presenter.present({ league, drivers, races });
return presenter;
}
async getSponsorSettings(sponsorId: string): Promise<{
profile: SponsorProfileDTO;
notifications: NotificationSettingsDTO;
privacy: PrivacySettingsDTO;
}> {
async getSponsorSettings(sponsorId: string): Promise<SponsorSettingsPresenter> {
this.logger.debug('[SponsorService] Fetching sponsor settings.', { sponsorId });
const presenter = new SponsorSettingsPresenter();
// Mock data
const profile: SponsorProfileDTO = {
companyName: 'Acme Racing Co.',
@@ -359,7 +499,8 @@ export class SponsorService {
contactEmail: 'sponsor@acme-racing.com',
contactPhone: '+1 (555) 123-4567',
website: 'https://acme-racing.com',
description: 'Premium sim racing equipment and accessories for competitive drivers.',
description:
'Premium sim racing equipment and accessories for competitive drivers.',
logoUrl: null,
industry: 'Racing Equipment',
address: {
@@ -392,7 +533,8 @@ export class SponsorService {
allowDirectContact: true,
};
return { profile, notifications, privacy };
presenter.present({ profile, notifications, privacy });
return presenter;
}
async updateSponsorSettings(
@@ -401,12 +543,15 @@ export class SponsorService {
profile?: Partial<SponsorProfileDTO>;
notifications?: Partial<NotificationSettingsDTO>;
privacy?: Partial<PrivacySettingsDTO>;
}
): Promise<void> {
},
): Promise<SponsorSettingsUpdatePresenter> {
this.logger.debug('[SponsorService] Updating sponsor settings.', { sponsorId, input });
// Mock implementation - in real app, this would persist to database
// For now, just log the update
this.logger.info('[SponsorService] Settings updated successfully.', { sponsorId });
const presenter = new SponsorSettingsUpdatePresenter();
presenter.present({ success: true });
return presenter;
}
}

View File

@@ -0,0 +1,42 @@
import type { AcceptSponsorshipOutputPort } from '@core/racing/application/ports/output/AcceptSponsorshipOutputPort';
export interface AcceptSponsorshipRequestResultViewModel {
requestId: string;
sponsorshipId: string;
status: 'accepted';
acceptedAt: Date;
platformFee: number;
netAmount: number;
}
export class AcceptSponsorshipRequestPresenter {
private result: AcceptSponsorshipRequestResultViewModel | null = null;
reset() {
this.result = null;
}
present(output: AcceptSponsorshipOutputPort | null) {
if (!output) {
this.result = null;
return;
}
this.result = {
requestId: output.requestId,
sponsorshipId: output.sponsorshipId,
status: output.status,
acceptedAt: output.acceptedAt,
platformFee: output.platformFee,
netAmount: output.netAmount,
};
}
getViewModel(): AcceptSponsorshipRequestResultViewModel | null {
return this.result;
}
get viewModel(): AcceptSponsorshipRequestResultViewModel | null {
return this.result;
}
}

View File

@@ -0,0 +1,21 @@
import { AvailableLeagueDTO } from '../dtos/AvailableLeagueDTO';
export class AvailableLeaguesPresenter {
private result: AvailableLeagueDTO[] | null = null;
reset() {
this.result = null;
}
present(leagues: AvailableLeagueDTO[]) {
this.result = leagues;
}
getViewModel(): AvailableLeagueDTO[] | null {
return this.result;
}
get viewModel(): AvailableLeagueDTO[] | null {
return this.result;
}
}

View File

@@ -1,4 +1,4 @@
import type { GetEntitySponsorshipPricingOutputPort } from '@core/racing/application/ports/output/GetEntitySponsorshipPricingOutputPort';
import type { GetSponsorshipPricingOutputPort } from '@core/racing/application/ports/output/GetSponsorshipPricingOutputPort';
import { GetEntitySponsorshipPricingResultDTO } from '../dtos/GetEntitySponsorshipPricingResultDTO';
export class GetEntitySponsorshipPricingPresenter {
@@ -8,34 +8,34 @@ export class GetEntitySponsorshipPricingPresenter {
this.result = null;
}
async present(output: GetEntitySponsorshipPricingOutputPort | null) {
present(output: GetSponsorshipPricingOutputPort | null) {
if (!output) {
this.result = { pricing: [] };
this.result = {
entityType: 'season',
entityId: '',
pricing: [],
};
return;
}
const pricing = [];
if (output.mainSlot) {
pricing.push({
id: `${output.entityType}-${output.entityId}-main`,
level: 'main',
price: output.mainSlot.price,
currency: output.mainSlot.currency,
});
}
if (output.secondarySlot) {
pricing.push({
id: `${output.entityType}-${output.entityId}-secondary`,
level: 'secondary',
price: output.secondarySlot.price,
currency: output.secondarySlot.currency,
});
}
this.result = { pricing };
this.result = {
entityType: output.entityType,
entityId: output.entityId,
pricing: output.pricing.map(item => ({
id: item.id,
level: item.level,
price: item.price,
currency: item.currency,
})),
};
}
getViewModel(): GetEntitySponsorshipPricingResultDTO | null {
return this.result;
}
}
get viewModel(): GetEntitySponsorshipPricingResultDTO {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -2,12 +2,31 @@ import type { PendingSponsorshipRequestsOutputPort } from '@core/racing/applicat
import { GetPendingSponsorshipRequestsOutputDTO } from '../dtos/GetPendingSponsorshipRequestsOutputDTO';
export class GetPendingSponsorshipRequestsPresenter {
present(outputPort: PendingSponsorshipRequestsOutputPort): GetPendingSponsorshipRequestsOutputDTO {
return {
private result: GetPendingSponsorshipRequestsOutputDTO | null = null;
reset() {
this.result = null;
}
present(outputPort: PendingSponsorshipRequestsOutputPort | null) {
if (!outputPort) {
this.result = null;
return;
}
this.result = {
entityType: outputPort.entityType,
entityId: outputPort.entityId,
requests: outputPort.requests,
totalCount: outputPort.totalCount,
};
}
}
getViewModel(): GetPendingSponsorshipRequestsOutputDTO | null {
return this.result;
}
get viewModel(): GetPendingSponsorshipRequestsOutputDTO | null {
return this.result;
}
}

View File

@@ -2,7 +2,21 @@ import type { SponsorDashboardOutputPort } from '@core/racing/application/ports/
import { SponsorDashboardDTO } from '../dtos/SponsorDashboardDTO';
export class GetSponsorDashboardPresenter {
present(outputPort: SponsorDashboardOutputPort | null): SponsorDashboardDTO | null {
return outputPort;
private result: SponsorDashboardDTO | null = null;
reset() {
this.result = null;
}
}
present(outputPort: SponsorDashboardOutputPort | null) {
this.result = outputPort ?? null;
}
getViewModel(): SponsorDashboardDTO | null {
return this.result;
}
get viewModel(): SponsorDashboardDTO | null {
return this.result;
}
}

View File

@@ -0,0 +1,42 @@
import { GetSponsorOutputDTO } from '../dtos/GetSponsorOutputDTO';
interface GetSponsorOutputPort {
sponsor: {
id: string;
name: string;
logoUrl?: string;
websiteUrl?: string;
};
}
export class GetSponsorPresenter {
private result: GetSponsorOutputDTO | null = null;
reset() {
this.result = null;
}
present(output: GetSponsorOutputPort | null) {
if (!output) {
this.result = null;
return;
}
this.result = {
sponsor: {
id: output.sponsor.id,
name: output.sponsor.name,
...(output.sponsor.logoUrl !== undefined ? { logoUrl: output.sponsor.logoUrl } : {}),
...(output.sponsor.websiteUrl !== undefined ? { websiteUrl: output.sponsor.websiteUrl } : {}),
},
} as GetSponsorOutputDTO;
}
getViewModel(): GetSponsorOutputDTO | null {
return this.result;
}
get viewModel(): GetSponsorOutputDTO | null {
return this.result;
}
}

View File

@@ -2,7 +2,21 @@ import type { SponsorSponsorshipsOutputPort } from '@core/racing/application/por
import { SponsorSponsorshipsDTO } from '../dtos/SponsorSponsorshipsDTO';
export class GetSponsorSponsorshipsPresenter {
present(outputPort: SponsorSponsorshipsOutputPort | null): SponsorSponsorshipsDTO | null {
return outputPort;
private result: SponsorSponsorshipsDTO | null = null;
reset() {
this.result = null;
}
}
present(outputPort: SponsorSponsorshipsOutputPort | null) {
this.result = outputPort ?? null;
}
getViewModel(): SponsorSponsorshipsDTO | null {
return this.result;
}
get viewModel(): SponsorSponsorshipsDTO | null {
return this.result;
}
}

View File

@@ -2,9 +2,24 @@ import type { GetSponsorsOutputPort } from '@core/racing/application/ports/outpu
import { GetSponsorsOutputDTO } from '../dtos/GetSponsorsOutputDTO';
export class GetSponsorsPresenter {
present(outputPort: GetSponsorsOutputPort): GetSponsorsOutputDTO {
return {
private result: GetSponsorsOutputDTO | null = null;
reset() {
this.result = null;
}
present(outputPort: GetSponsorsOutputPort) {
this.result = {
sponsors: outputPort.sponsors,
};
}
}
getViewModel(): GetSponsorsOutputDTO | null {
return this.result;
}
get viewModel(): GetSponsorsOutputDTO {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,29 @@
import { LeagueDetailDTO } from '../dtos/LeagueDetailDTO';
import { DriverDTO } from '../dtos/DriverDTO';
import { RaceDTO } from '../dtos/RaceDTO';
export interface LeagueDetailViewModel {
league: LeagueDetailDTO;
drivers: DriverDTO[];
races: RaceDTO[];
}
export class LeagueDetailPresenter {
private result: LeagueDetailViewModel | null = null;
reset() {
this.result = null;
}
present(viewModel: LeagueDetailViewModel) {
this.result = viewModel;
}
getViewModel(): LeagueDetailViewModel | null {
return this.result;
}
get viewModel(): LeagueDetailViewModel | null {
return this.result;
}
}

View File

@@ -0,0 +1,21 @@
import type { RejectSponsorshipRequestResultDTO } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
export class RejectSponsorshipRequestPresenter {
private result: RejectSponsorshipRequestResultDTO | null = null;
reset() {
this.result = null;
}
present(output: RejectSponsorshipRequestResultDTO | null) {
this.result = output ?? null;
}
getViewModel(): RejectSponsorshipRequestResultDTO | null {
return this.result;
}
get viewModel(): RejectSponsorshipRequestResultDTO | null {
return this.result;
}
}

View File

@@ -0,0 +1,30 @@
import { PaymentMethodDTO } from '../dtos/PaymentMethodDTO';
import { InvoiceDTO } from '../dtos/InvoiceDTO';
import { BillingStatsDTO } from '../dtos/BillingStatsDTO';
export interface SponsorBillingViewModel {
paymentMethods: PaymentMethodDTO[];
invoices: InvoiceDTO[];
stats: BillingStatsDTO;
}
export class SponsorBillingPresenter {
private result: SponsorBillingViewModel | null = null;
reset() {
this.result = null;
}
present(viewModel: SponsorBillingViewModel) {
this.result = viewModel;
}
getViewModel(): SponsorBillingViewModel | null {
return this.result;
}
get viewModel(): SponsorBillingViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,29 @@
import { SponsorProfileDTO } from '../dtos/SponsorProfileDTO';
import { NotificationSettingsDTO } from '../dtos/NotificationSettingsDTO';
import { PrivacySettingsDTO } from '../dtos/PrivacySettingsDTO';
export interface SponsorSettingsViewModel {
profile: SponsorProfileDTO;
notifications: NotificationSettingsDTO;
privacy: PrivacySettingsDTO;
}
export class SponsorSettingsPresenter {
private result: SponsorSettingsViewModel | null = null;
reset() {
this.result = null;
}
present(viewModel: SponsorSettingsViewModel) {
this.result = viewModel;
}
getViewModel(): SponsorSettingsViewModel | null {
return this.result;
}
get viewModel(): SponsorSettingsViewModel | null {
return this.result;
}
}

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