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!"', () => { 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 { Injectable } from '@nestjs/common';
import { HelloPresenter } from './presenters/HelloPresenter';
@Injectable() @Injectable()
export class HelloService { export class HelloService {
getHello(): string { getHello(): HelloPresenter {
return 'Hello World!'; 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', userAgent: 'Mozilla/5.0',
country: 'US', country: 'US',
}; };
const output = { pageViewId: 'pv-123' }; const presenterMock = { viewModel: { pageViewId: 'pv-123' } };
service.recordPageView.mockResolvedValue(output); service.recordPageView.mockResolvedValue(presenterMock as any);
const mockRes: ReturnType<typeof vi.mocked<Response>> = { const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(), status: vi.fn().mockReturnThis(),
@@ -54,7 +54,7 @@ describe('AnalyticsController', () => {
expect(service.recordPageView).toHaveBeenCalledWith(input); expect(service.recordPageView).toHaveBeenCalledWith(input);
expect(mockRes.status).toHaveBeenCalledWith(201); 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', actorId: 'actor-789',
metadata: { key: 'value' }, metadata: { key: 'value' },
}; };
const output = { eventId: 'event-123', engagementWeight: 10 }; const presenterMock = { eventId: 'event-123', engagementWeight: 10 };
service.recordEngagement.mockResolvedValue(output); service.recordEngagement.mockResolvedValue(presenterMock as any);
const mockRes: ReturnType<typeof vi.mocked<Response>> = { const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(), status: vi.fn().mockReturnThis(),
@@ -81,41 +81,45 @@ describe('AnalyticsController', () => {
expect(service.recordEngagement).toHaveBeenCalledWith(input); expect(service.recordEngagement).toHaveBeenCalledWith(input);
expect(mockRes.status).toHaveBeenCalledWith(201); expect(mockRes.status).toHaveBeenCalledWith(201);
expect(mockRes.json).toHaveBeenCalledWith(output); expect(mockRes.json).toHaveBeenCalledWith((presenterMock as any).viewModel);
}); });
}); });
describe('getDashboardData', () => { describe('getDashboardData', () => {
it('should return dashboard data', async () => { it('should return dashboard data', async () => {
const output = { const presenterMock = {
totalUsers: 100, viewModel: {
activeUsers: 50, totalUsers: 100,
totalRaces: 20, activeUsers: 50,
totalLeagues: 5, totalRaces: 20,
totalLeagues: 5,
},
}; };
service.getDashboardData.mockResolvedValue(output); service.getDashboardData.mockResolvedValue(presenterMock as any);
const result = await controller.getDashboardData(); const result = await controller.getDashboardData();
expect(service.getDashboardData).toHaveBeenCalled(); expect(service.getDashboardData).toHaveBeenCalled();
expect(result).toEqual(output); expect(result).toEqual(presenterMock.viewModel);
}); });
}); });
describe('getAnalyticsMetrics', () => { describe('getAnalyticsMetrics', () => {
it('should return analytics metrics', async () => { it('should return analytics metrics', async () => {
const output = { const presenterMock = {
pageViews: 1000, viewModel: {
uniqueVisitors: 500, pageViews: 1000,
averageSessionDuration: 300, uniqueVisitors: 500,
bounceRate: 0.4, averageSessionDuration: 300,
bounceRate: 0.4,
},
}; };
service.getAnalyticsMetrics.mockResolvedValue(output); service.getAnalyticsMetrics.mockResolvedValue(presenterMock as any);
const result = await controller.getAnalyticsMetrics(); const result = await controller.getAnalyticsMetrics();
expect(service.getAnalyticsMetrics).toHaveBeenCalled(); 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'; import { AnalyticsService } from './AnalyticsService';
type RecordPageViewInput = RecordPageViewInputDTO; type RecordPageViewInput = RecordPageViewInputDTO;
type RecordPageViewOutput = RecordPageViewOutputDTO;
type RecordEngagementInput = RecordEngagementInputDTO; type RecordEngagementInput = RecordEngagementInputDTO;
type RecordEngagementOutput = RecordEngagementOutputDTO;
type GetDashboardDataOutput = GetDashboardDataOutputDTO;
type GetAnalyticsMetricsOutput = GetAnalyticsMetricsOutputDTO;
@ApiTags('analytics') @ApiTags('analytics')
@Controller('analytics') @Controller('analytics')
@@ -31,8 +27,8 @@ export class AnalyticsController {
@Body() input: RecordPageViewInput, @Body() input: RecordPageViewInput,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
const output: RecordPageViewOutput = await this.analyticsService.recordPageView(input); const presenter = await this.analyticsService.recordPageView(input);
res.status(HttpStatus.CREATED).json(output); res.status(HttpStatus.CREATED).json(presenter.viewModel);
} }
@Post('engagement') @Post('engagement')
@@ -43,21 +39,23 @@ export class AnalyticsController {
@Body() input: RecordEngagementInput, @Body() input: RecordEngagementInput,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
const output: RecordEngagementOutput = await this.analyticsService.recordEngagement(input); const presenter = await this.analyticsService.recordEngagement(input);
res.status(HttpStatus.CREATED).json(output); res.status(HttpStatus.CREATED).json(presenter.viewModel);
} }
@Get('dashboard') @Get('dashboard')
@ApiOperation({ summary: 'Get analytics dashboard data' }) @ApiOperation({ summary: 'Get analytics dashboard data' })
@ApiResponse({ status: 200, description: 'Dashboard data', type: GetDashboardDataOutputDTO }) @ApiResponse({ status: 200, description: 'Dashboard data', type: GetDashboardDataOutputDTO })
async getDashboardData(): Promise<GetDashboardDataOutput> { async getDashboardData(): Promise<GetDashboardDataOutputDTO> {
return await this.analyticsService.getDashboardData(); const presenter = await this.analyticsService.getDashboardData();
return presenter.viewModel;
} }
@Get('metrics') @Get('metrics')
@ApiOperation({ summary: 'Get analytics metrics' }) @ApiOperation({ summary: 'Get analytics metrics' })
@ApiResponse({ status: 200, description: 'Analytics metrics', type: GetAnalyticsMetricsOutputDTO }) @ApiResponse({ status: 200, description: 'Analytics metrics', type: GetAnalyticsMetricsOutputDTO })
async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsOutput> { async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsOutputDTO> {
return await this.analyticsService.getAnalyticsMetrics(); 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 { RecordEngagementUseCase } from '@core/analytics/application/use-cases/RecordEngagementUseCase';
import { GetDashboardDataUseCase } from '@core/analytics/application/use-cases/GetDashboardDataUseCase'; import { GetDashboardDataUseCase } from '@core/analytics/application/use-cases/GetDashboardDataUseCase';
import { GetAnalyticsMetricsUseCase } from '@core/analytics/application/use-cases/GetAnalyticsMetricsUseCase'; 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 RecordPageViewInput = RecordPageViewInputDTO;
type RecordPageViewOutput = RecordPageViewOutputDTO;
type RecordEngagementInput = RecordEngagementInputDTO; type RecordEngagementInput = RecordEngagementInputDTO;
type RecordEngagementOutput = RecordEngagementOutputDTO;
type GetDashboardDataOutput = GetDashboardDataOutputDTO;
type GetAnalyticsMetricsOutput = GetAnalyticsMetricsOutputDTO;
const RECORD_PAGE_VIEW_USE_CASE_TOKEN = 'RecordPageViewUseCase_TOKEN'; const RECORD_PAGE_VIEW_USE_CASE_TOKEN = 'RecordPageViewUseCase_TOKEN';
const RECORD_ENGAGEMENT_USE_CASE_TOKEN = 'RecordEngagementUseCase_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, @Inject(GET_ANALYTICS_METRICS_USE_CASE_TOKEN) private readonly getAnalyticsMetricsUseCase: GetAnalyticsMetricsUseCase,
) {} ) {}
async recordPageView(input: RecordPageViewInput): Promise<RecordPageViewOutput> { async recordPageView(input: RecordPageViewInput): Promise<RecordPageViewPresenter> {
return await this.recordPageViewUseCase.execute(input); const presenter = new RecordPageViewPresenter();
await this.recordPageViewUseCase.execute(input, presenter);
return presenter;
} }
async recordEngagement(input: RecordEngagementInput): Promise<RecordEngagementOutput> { async recordEngagement(input: RecordEngagementInput): Promise<RecordEngagementPresenter> {
return await this.recordEngagementUseCase.execute(input); const presenter = new RecordEngagementPresenter();
await this.recordEngagementUseCase.execute(input, presenter);
return presenter;
} }
async getDashboardData(): Promise<GetDashboardDataOutput> { async getDashboardData(): Promise<GetDashboardDataPresenter> {
return await this.getDashboardDataUseCase.execute(); const presenter = new GetDashboardDataPresenter();
await this.getDashboardDataUseCase.execute(undefined, presenter);
return presenter;
} }
async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsOutput> { async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsPresenter> {
return await this.getAnalyticsMetricsUseCase.execute(); 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') @Post('signup')
async signup(@Body() params: SignupParams): Promise<AuthSessionDTO> { async signup(@Body() params: SignupParams): Promise<AuthSessionDTO> {
return this.authService.signupWithEmail(params); const presenter = await this.authService.signupWithEmail(params);
return presenter.viewModel;
} }
@Post('login') @Post('login')
async login(@Body() params: LoginParams): Promise<AuthSessionDTO> { async login(@Body() params: LoginParams): Promise<AuthSessionDTO> {
return this.authService.loginWithEmail(params); const presenter = await this.authService.loginWithEmail(params);
return presenter.viewModel;
} }
@Get('session') @Get('session')
async getSession(): Promise<AuthSessionDTO | null> { async getSession(): Promise<AuthSessionDTO | null> {
return this.authService.getCurrentSession(); const presenter = await this.authService.getCurrentSession();
return presenter ? presenter.viewModel : null;
} }
@Post('logout') @Post('logout')
async logout(): Promise<void> { async logout(): Promise<{ success: boolean }> {
return this.authService.logout(); 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 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 { 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 { AuthSessionDTO, LoginParams, SignupParams, AuthenticatedUserDTO } from './dtos/AuthDto';
import { AuthSessionPresenter } from './presenters/AuthSessionPresenter';
import { CommandResultPresenter } from './presenters/CommandResultPresenter';
@Injectable() @Injectable()
export class AuthService { 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.'); this.logger.debug('[AuthService] Attempting to get current session.');
const coreSession = await this.identitySessionPort.getCurrentSession(); const coreSession = await this.identitySessionPort.getCurrentSession();
if (!coreSession) { if (!coreSession) {
@@ -59,21 +61,20 @@ export class AuthService {
const user = await this.userRepository.findById(coreSession.user.id); // Use userRepository to fetch full user const user = await this.userRepository.findById(coreSession.user.id); // Use userRepository to fetch full user
if (!user) { if (!user) {
// If session exists but user doesn't in DB, perhaps clear session? // 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.`); 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 await this.identitySessionPort.clearSession(); // Clear potentially stale session
return null; return null;
} }
const authenticatedUserDTO = this.mapUserToAuthenticatedUserDTO(User.fromStored(user)); const authenticatedUserDTO = this.mapUserToAuthenticatedUserDTO(User.fromStored(user));
return { const presenter = new AuthSessionPresenter();
token: coreSession.token, presenter.present({ token: coreSession.token, user: authenticatedUserDTO });
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}`); this.logger.debug(`[AuthService] Attempting signup for email: ${params.email}`);
const user = await this.signupUseCase.execute(params.email, params.password, params.displayName); const user = await this.signupUseCase.execute(params.email, params.password, params.displayName);
@@ -82,13 +83,12 @@ export class AuthService {
const coreDto = this.mapToCoreAuthenticatedUserDTO(authenticatedUserDTO); const coreDto = this.mapToCoreAuthenticatedUserDTO(authenticatedUserDTO);
const session = await this.identitySessionPort.createSession(coreDto); const session = await this.identitySessionPort.createSession(coreDto);
return { const presenter = new AuthSessionPresenter();
token: session.token, presenter.present({ token: session.token, user: authenticatedUserDTO });
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}`); this.logger.debug(`[AuthService] Attempting login for email: ${params.email}`);
try { try {
const user = await this.loginUseCase.execute(params.email, params.password); const user = await this.loginUseCase.execute(params.email, params.password);
@@ -97,10 +97,9 @@ export class AuthService {
const coreDto = this.mapToCoreAuthenticatedUserDTO(authenticatedUserDTO); const coreDto = this.mapToCoreAuthenticatedUserDTO(authenticatedUserDTO);
const session = await this.identitySessionPort.createSession(coreDto); const session = await this.identitySessionPort.createSession(coreDto);
return { const presenter = new AuthSessionPresenter();
token: session.token, presenter.present({ token: session.token, user: authenticatedUserDTO });
user: authenticatedUserDTO, return presenter;
};
} catch (error) { } catch (error) {
this.logger.error(`[AuthService] Login failed for email ${params.email}:`, error instanceof Error ? error : new Error(String(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.'); 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.'); this.logger.debug('[AuthService] Attempting logout.');
const presenter = new CommandResultPresenter();
await this.logoutUseCase.execute(); 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' }) @ApiQuery({ name: 'driverId', description: 'Driver ID' })
@ApiResponse({ status: 200, description: 'Dashboard overview', type: DashboardOverviewDTO }) @ApiResponse({ status: 200, description: 'Dashboard overview', type: DashboardOverviewDTO })
async getDashboardOverview(@Query('driverId') driverId: string): Promise<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 { Injectable, Inject } from '@nestjs/common';
import { plainToClass } from 'class-transformer';
import { DashboardOverviewUseCase } from '@core/racing/application/use-cases/DashboardOverviewUseCase'; import { DashboardOverviewUseCase } from '@core/racing/application/use-cases/DashboardOverviewUseCase';
import type { DashboardOverviewOutputPort } from '@core/racing/application/ports/output/DashboardOverviewOutputPort'; import type { DashboardOverviewOutputPort } from '@core/racing/application/ports/output/DashboardOverviewOutputPort';
import { DashboardOverviewDTO } from './dtos/DashboardOverviewDTO'; import { DashboardOverviewDTO } from './dtos/DashboardOverviewDTO';
import { DashboardOverviewPresenter } from './presenters/DashboardOverviewPresenter';
// Core imports // Core imports
import type { Logger } from '@core/shared/application/Logger'; 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 }); this.logger.debug('[DashboardService] Getting dashboard overview:', { driverId });
const result = await this.dashboardOverviewUseCase.execute({ 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'); 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', () => { describe('getDriversLeaderboard', () => {
it('should return drivers leaderboard', async () => { it('should return drivers leaderboard', async () => {
const leaderboard: DriversLeaderboardDTO = { items: [] }; const leaderboard: DriversLeaderboardDTO = { items: [] };
service.getDriversLeaderboard.mockResolvedValue(leaderboard); service.getDriversLeaderboard.mockResolvedValue({ viewModel: leaderboard } as never);
const result = await controller.getDriversLeaderboard(); const result = await controller.getDriversLeaderboard();
@@ -58,7 +58,7 @@ describe('DriverController', () => {
describe('getTotalDrivers', () => { describe('getTotalDrivers', () => {
it('should return total drivers stats', async () => { it('should return total drivers stats', async () => {
const stats: DriverStatsDTO = { totalDrivers: 100 }; const stats: DriverStatsDTO = { totalDrivers: 100 };
service.getTotalDrivers.mockResolvedValue(stats); service.getTotalDrivers.mockResolvedValue({ viewModel: stats } as never);
const result = await controller.getTotalDrivers(); const result = await controller.getTotalDrivers();
@@ -70,8 +70,8 @@ describe('DriverController', () => {
describe('getCurrentDriver', () => { describe('getCurrentDriver', () => {
it('should return current driver if userId exists', async () => { it('should return current driver if userId exists', async () => {
const userId = 'user-123'; const userId = 'user-123';
const driver: GetDriverOutputDTO = { id: 'driver-123', name: 'Driver' }; const driver: GetDriverOutputDTO = { id: 'driver-123', name: 'Driver' } as GetDriverOutputDTO;
service.getCurrentDriver.mockResolvedValue(driver); service.getCurrentDriver.mockResolvedValue({ viewModel: driver } as never);
const mockReq: Partial<AuthenticatedRequest> = { user: { userId } }; const mockReq: Partial<AuthenticatedRequest> = { user: { userId } };
@@ -94,9 +94,9 @@ describe('DriverController', () => {
describe('completeOnboarding', () => { describe('completeOnboarding', () => {
it('should complete onboarding', async () => { it('should complete onboarding', async () => {
const userId = 'user-123'; const userId = 'user-123';
const input: CompleteOnboardingInputDTO = { someField: 'value' }; const input: CompleteOnboardingInputDTO = { someField: 'value' } as CompleteOnboardingInputDTO;
const output: CompleteOnboardingOutputDTO = { success: true }; const output: CompleteOnboardingOutputDTO = { success: true };
service.completeOnboarding.mockResolvedValue(output); service.completeOnboarding.mockResolvedValue({ viewModel: output } as never);
const mockReq: Partial<AuthenticatedRequest> = { user: { userId } }; const mockReq: Partial<AuthenticatedRequest> = { user: { userId } };
@@ -111,8 +111,8 @@ describe('DriverController', () => {
it('should return registration status', async () => { it('should return registration status', async () => {
const driverId = 'driver-123'; const driverId = 'driver-123';
const raceId = 'race-456'; const raceId = 'race-456';
const status: DriverRegistrationStatusDTO = { registered: true }; const status: DriverRegistrationStatusDTO = { registered: true } as DriverRegistrationStatusDTO;
service.getDriverRegistrationStatus.mockResolvedValue(status); service.getDriverRegistrationStatus.mockResolvedValue({ viewModel: status } as never);
const result = await controller.getDriverRegistrationStatus(driverId, raceId); const result = await controller.getDriverRegistrationStatus(driverId, raceId);
@@ -124,8 +124,8 @@ describe('DriverController', () => {
describe('getDriver', () => { describe('getDriver', () => {
it('should return driver by id', async () => { it('should return driver by id', async () => {
const driverId = 'driver-123'; const driverId = 'driver-123';
const driver: GetDriverOutputDTO = { id: driverId, name: 'Driver' }; const driver: GetDriverOutputDTO = { id: driverId, name: 'Driver' } as GetDriverOutputDTO;
service.getDriver.mockResolvedValue(driver); service.getDriver.mockResolvedValue({ viewModel: driver } as never);
const result = await controller.getDriver(driverId); const result = await controller.getDriver(driverId);

View File

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

View File

@@ -1,12 +1,6 @@
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import { DriversLeaderboardDTO } from './dtos/DriversLeaderboardDTO';
import { DriverStatsDTO } from './dtos/DriverStatsDTO';
import { CompleteOnboardingInputDTO } from './dtos/CompleteOnboardingInputDTO'; import { CompleteOnboardingInputDTO } from './dtos/CompleteOnboardingInputDTO';
import { CompleteOnboardingOutputDTO } from './dtos/CompleteOnboardingOutputDTO';
import { GetDriverRegistrationStatusQueryDTO } from './dtos/GetDriverRegistrationStatusQueryDTO'; import { GetDriverRegistrationStatusQueryDTO } from './dtos/GetDriverRegistrationStatusQueryDTO';
import { DriverRegistrationStatusDTO } from './dtos/DriverRegistrationStatusDTO';
import { GetDriverOutputDTO } from './dtos/GetDriverOutputDTO';
import { GetDriverProfileOutputDTO } from './dtos/GetDriverProfileOutputDTO';
// Use cases // Use cases
import { GetDriversLeaderboardUseCase } from '@core/racing/application/use-cases/GetDriversLeaderboardUseCase'; 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 { CompleteDriverOnboardingUseCase } from '@core/racing/application/use-cases/CompleteDriverOnboardingUseCase';
import { IsDriverRegisteredForRaceUseCase } from '@core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase'; import { IsDriverRegisteredForRaceUseCase } from '@core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase';
import { GetProfileOverviewUseCase } from '@core/racing/application/use-cases/GetProfileOverviewUseCase'; import { GetProfileOverviewUseCase } from '@core/racing/application/use-cases/GetProfileOverviewUseCase';
import { UpdateDriverProfileUseCase } from '@core/racing/application/use-cases/UpdateDriverProfileUseCase';
// Presenters // Presenters
import { DriverStatsPresenter } from './presenters/DriverStatsPresenter'; 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 // 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 {
import type { ProfileOverviewOutputPort } from '@core/racing/application/ports/output/ProfileOverviewOutputPort'; 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 type { Logger } from '@core/shared/application';
import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository'; import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
import { UpdateDriverProfileUseCase } from '@core/racing/application/use-cases/UpdateDriverProfileUseCase';
@Injectable() @Injectable()
export class DriverService { export class DriverService {
constructor( constructor(
@Inject(GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN) private readonly getDriversLeaderboardUseCase: GetDriversLeaderboardUseCase, @Inject(GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN)
@Inject(GET_TOTAL_DRIVERS_USE_CASE_TOKEN) private readonly getTotalDriversUseCase: GetTotalDriversUseCase, private readonly getDriversLeaderboardUseCase: GetDriversLeaderboardUseCase,
@Inject(COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN) private readonly completeDriverOnboardingUseCase: CompleteDriverOnboardingUseCase, @Inject(GET_TOTAL_DRIVERS_USE_CASE_TOKEN)
@Inject(IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN) private readonly isDriverRegisteredForRaceUseCase: IsDriverRegisteredForRaceUseCase, private readonly getTotalDriversUseCase: GetTotalDriversUseCase,
@Inject(UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN) private readonly updateDriverProfileUseCase: UpdateDriverProfileUseCase, @Inject(COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN)
@Inject(GET_PROFILE_OVERVIEW_USE_CASE_TOKEN) private readonly getProfileOverviewUseCase: GetProfileOverviewUseCase, private readonly completeDriverOnboardingUseCase: CompleteDriverOnboardingUseCase,
@Inject(DRIVER_REPOSITORY_TOKEN) private readonly driverRepository: IDriverRepository, @Inject(IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN)
@Inject(LOGGER_TOKEN) private readonly logger: Logger, 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.'); this.logger.debug('[DriverService] Fetching drivers leaderboard.');
const result = await this.getDriversLeaderboardUseCase.execute(); const result = await this.getDriversLeaderboardUseCase.execute();
if (result.isOk()) { if (result.isErr()) {
return result.value as DriversLeaderboardDTO; throw new Error(`Failed to fetch drivers leaderboard: ${result.unwrapErr().details.message}`);
} else {
throw new Error(`Failed to fetch drivers leaderboard: ${result.error.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.'); this.logger.debug('[DriverService] Fetching total drivers count.');
const result = await this.getTotalDriversUseCase.execute(); const result = await this.getTotalDriversUseCase.execute();
@@ -58,11 +76,12 @@ export class DriverService {
} }
const presenter = new DriverStatsPresenter(); const presenter = new DriverStatsPresenter();
presenter.reset();
presenter.present(result.unwrap()); 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); this.logger.debug('Completing onboarding for user:', userId);
const result = await this.completeDriverOnboardingUseCase.execute({ const result = await this.completeDriverOnboardingUseCase.execute({
@@ -75,80 +94,88 @@ export class DriverService {
bio: input.bio, bio: input.bio,
}); });
const presenter = new CompleteOnboardingPresenter();
presenter.reset();
if (result.isOk()) { if (result.isOk()) {
return { presenter.present(result.value);
success: true,
driverId: result.value.driverId,
};
} else { } else {
return { presenter.presentError(result.error.code);
success: false,
errorMessage: 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); this.logger.debug('Checking driver registration status:', query);
const result = await this.isDriverRegisteredForRaceUseCase.execute({ raceId: query.raceId, driverId: query.driverId }); const result = await this.isDriverRegisteredForRaceUseCase.execute({
if (result.isOk()) { raceId: query.raceId,
return result.value; driverId: query.driverId,
} 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}`); 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}`); this.logger.debug(`[DriverService] Fetching current driver for userId: ${userId}`);
const driver = await this.driverRepository.findById(userId); const driver = await this.driverRepository.findById(userId);
if (!driver) {
return null;
}
return { const presenter = new DriverPresenter();
id: driver.id, presenter.reset();
iracingId: driver.iracingId.value, presenter.present(driver ?? null);
name: driver.name.value,
country: driver.country.value, return presenter;
bio: driver.bio?.value,
joinedAt: driver.joinedAt.toISOString(),
};
} }
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}`); this.logger.debug(`[DriverService] Updating driver profile for driverId: ${driverId}`);
const result = await this.updateDriverProfileUseCase.execute({ driverId, bio, country }); const result = await this.updateDriverProfileUseCase.execute({ driverId, bio, country });
const presenter = new DriverPresenter();
presenter.reset();
if (result.isErr()) { if (result.isErr()) {
this.logger.error(`Failed to update driver profile: ${result.error.code}`); 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}`); this.logger.debug(`[DriverService] Fetching driver for driverId: ${driverId}`);
const driver = await this.driverRepository.findById(driverId); const driver = await this.driverRepository.findById(driverId);
if (!driver) {
return null;
}
return { const presenter = new DriverPresenter();
id: driver.id, presenter.reset();
iracingId: driver.iracingId.value, presenter.present(driver ?? null);
name: driver.name.value,
country: driver.country.value, return presenter;
bio: driver.bio?.value,
joinedAt: driver.joinedAt.toISOString(),
};
} }
async getDriverProfile(driverId: string): Promise<GetDriverProfileOutputDTO> { async getDriverProfile(driverId: string): Promise<DriverProfilePresenter> {
this.logger.debug(`[DriverService] Fetching driver profile for driverId: ${driverId}`); this.logger.debug(`[DriverService] Fetching driver profile for driverId: ${driverId}`);
const result = await this.getProfileOverviewUseCase.execute({ 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}`); throw new Error(`Failed to fetch driver profile: ${result.error.code}`);
} }
const outputPort = result.value; const presenter = new DriverProfilePresenter();
return this.mapProfileOverviewToDTO(outputPort); presenter.reset();
} presenter.present(result.value);
private mapProfileOverviewToDTO(outputPort: ProfileOverviewOutputPort): GetDriverProfileOutputDTO { return presenter;
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,
};
} }
} }

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,13 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean } from 'class-validator'; import { IsBoolean, IsOptional, IsString } from 'class-validator';
export class UpdateLeagueMemberRoleOutputDTO { export class UpdateLeagueMemberRoleOutputDTO {
@ApiProperty() @ApiProperty()
@IsBoolean() @IsBoolean()
success: boolean; 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 { ApproveLeagueJoinRequestResultPort } from '@core/racing/application/ports/output/ApproveLeagueJoinRequestResultPort';
import type { ApproveLeagueJoinRequestDTO } from '../dtos/ApproveLeagueJoinRequestDTO'; import type { ApproveLeagueJoinRequestDTO } from '../dtos/ApproveLeagueJoinRequestDTO';
export function mapApproveLeagueJoinRequestPortToDTO(port: ApproveLeagueJoinRequestResultPort): ApproveLeagueJoinRequestDTO { export class ApproveLeagueJoinRequestPresenter {
return port; 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 { GetLeagueOwnerSummaryOutputPort } from '@core/racing/application/ports/output/GetLeagueOwnerSummaryOutputPort';
import { LeagueOwnerSummaryDTO } from '../dtos/LeagueOwnerSummaryDTO'; import { LeagueOwnerSummaryDTO } from '../dtos/LeagueOwnerSummaryDTO';
export function mapGetLeagueOwnerSummaryOutputPortToDTO(output: GetLeagueOwnerSummaryOutputPort): LeagueOwnerSummaryDTO | null { export class GetLeagueOwnerSummaryPresenter {
if (!output.summary) return null; private result: LeagueOwnerSummaryDTO | null = null;
return { reset() {
driver: { this.result = null;
id: output.summary.driver.id, }
iracingId: output.summary.driver.iracingId,
name: output.summary.driver.name, present(output: GetLeagueOwnerSummaryOutputPort) {
country: output.summary.driver.country, if (!output.summary) {
bio: output.summary.driver.bio, this.result = null;
joinedAt: output.summary.driver.joinedAt, return;
}, }
rating: output.summary.rating,
rank: output.summary.rank, 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 { export class GetLeagueProtestsPresenter {
const protests: ProtestDTO[] = output.protests.map((protest) => { private result: LeagueAdminProtestsDTO | null = null;
const race = output.racesById[protest.raceId];
return { reset() {
id: protest.id, this.result = null;
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 } = {}; present(output: GetLeagueProtestsOutputPort, leagueName?: string) {
for (const raceId in output.racesById) { const protests: ProtestDTO[] = output.protests.map((protest) => {
const race = output.racesById[raceId]; const race = output.racesById[protest.raceId];
racesById[raceId] = {
id: race.id, return {
name: race.track, id: protest.id,
date: race.scheduledAt, leagueId: race?.leagueId || '',
leagueName, 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 } = {}; getViewModel(): LeagueAdminProtestsDTO | null {
for (const driverId in output.driversById) { return this.result;
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,
};
} }
return {
protests,
racesById,
driversById,
};
} }

View File

@@ -1,14 +1,26 @@
import { GetLeagueSeasonsOutputPort } from '@core/racing/application/ports/output/GetLeagueSeasonsOutputPort'; import { GetLeagueSeasonsOutputPort } from '@core/racing/application/ports/output/GetLeagueSeasonsOutputPort';
import { LeagueSeasonSummaryDTO } from '../dtos/LeagueSeasonSummaryDTO'; import { LeagueSeasonSummaryDTO } from '../dtos/LeagueSeasonSummaryDTO';
export function mapGetLeagueSeasonsOutputPortToDTO(output: GetLeagueSeasonsOutputPort): LeagueSeasonSummaryDTO[] { export class GetLeagueSeasonsPresenter {
return output.seasons.map(season => ({ private result: LeagueSeasonSummaryDTO[] | null = null;
seasonId: season.seasonId,
name: season.name, reset() {
status: season.status, this.result = null;
startDate: season.startDate, }
endDate: season.endDate,
isPrimary: season.isPrimary, present(output: GetLeagueSeasonsOutputPort) {
isParallelActive: season.isParallelActive, 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 { JoinLeagueOutputPort } from '@core/racing/application/ports/output/JoinLeagueOutputPort';
import type { JoinLeagueOutputDTO } from '../dtos/JoinLeagueOutputDTO'; import type { JoinLeagueOutputDTO } from '../dtos/JoinLeagueOutputDTO';
export function mapJoinLeagueOutputPortToDTO(port: JoinLeagueOutputPort): JoinLeagueOutputDTO { export class JoinLeaguePresenter {
return { private result: JoinLeagueOutputDTO | null = null;
success: true,
membershipId: port.membershipId, 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 { LeagueScheduleDTO } from '../dtos/LeagueScheduleDTO';
import { RaceDTO } from '../../race/dtos/RaceDTO'; import { RaceDTO } from '../../race/dtos/RaceDTO';
export function mapGetLeagueScheduleOutputPortToDTO(output: GetLeagueScheduleOutputPort, leagueName?: string): LeagueScheduleDTO { export class LeagueSchedulePresenter {
return { private result: LeagueScheduleDTO | null = null;
races: output.races.map<RaceDTO>(race => ({
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, id: race.id,
name: race.name, name: race.name,
date: race.scheduledAt.toISOString(), date: race.scheduledAt.toISOString(),
leagueName, leagueName,
})), }));
}; }
}
export function mapGetLeagueScheduleOutputPortToRaceDTOs(output: GetLeagueScheduleOutputPort, leagueName?: string): RaceDTO[] { getViewModel(): RaceDTO[] | null {
return output.races.map<RaceDTO>(race => ({ return this.result;
id: race.id, }
name: race.name,
date: race.scheduledAt.toISOString(),
leagueName,
}));
} }

View File

@@ -1,9 +1,21 @@
import type { RejectLeagueJoinRequestOutputPort } from '@core/racing/application/ports/output/RejectLeagueJoinRequestOutputPort'; import type { RejectLeagueJoinRequestOutputPort } from '@core/racing/application/ports/output/RejectLeagueJoinRequestOutputPort';
import type { RejectJoinRequestOutputDTO } from '../dtos/RejectJoinRequestOutputDTO'; import type { RejectJoinRequestOutputDTO } from '../dtos/RejectJoinRequestOutputDTO';
export function mapRejectLeagueJoinRequestOutputPortToDTO(port: RejectLeagueJoinRequestOutputPort): RejectJoinRequestOutputDTO { export class RejectLeagueJoinRequestPresenter {
return { private result: RejectJoinRequestOutputDTO | null = null;
success: port.success,
message: port.message, 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 { RemoveLeagueMemberOutputPort } from '@core/racing/application/ports/output/RemoveLeagueMemberOutputPort';
import type { RemoveLeagueMemberOutputDTO } from '../dtos/RemoveLeagueMemberOutputDTO'; import type { RemoveLeagueMemberOutputDTO } from '../dtos/RemoveLeagueMemberOutputDTO';
export function mapRemoveLeagueMemberOutputPortToDTO(port: RemoveLeagueMemberOutputPort): RemoveLeagueMemberOutputDTO { export class RemoveLeagueMemberPresenter {
return { private result: RemoveLeagueMemberOutputDTO | null = null;
success: port.success,
}; 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 { TransferLeagueOwnershipOutputPort } from '@core/racing/application/ports/output/TransferLeagueOwnershipOutputPort';
import type { TransferLeagueOwnershipOutputDTO } from '../dtos/TransferLeagueOwnershipOutputDTO'; import type { TransferLeagueOwnershipOutputDTO } from '../dtos/TransferLeagueOwnershipOutputDTO';
export function mapTransferLeagueOwnershipOutputPortToDTO(port: TransferLeagueOwnershipOutputPort): TransferLeagueOwnershipOutputDTO { export class TransferLeagueOwnershipPresenter {
return { private result: TransferLeagueOwnershipOutputDTO | null = null;
success: port.success,
}; 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 { UpdateLeagueMemberRoleOutputPort } from '@core/racing/application/ports/output/UpdateLeagueMemberRoleOutputPort';
import type { UpdateLeagueMemberRoleOutputDTO } from '../dtos/UpdateLeagueMemberRoleOutputDTO'; import type { UpdateLeagueMemberRoleOutputDTO } from '../dtos/UpdateLeagueMemberRoleOutputDTO';
export function mapUpdateLeagueMemberRoleOutputPortToDTO(port: UpdateLeagueMemberRoleOutputPort): UpdateLeagueMemberRoleOutputDTO { export class UpdateLeagueMemberRolePresenter {
return { private result: UpdateLeagueMemberRoleOutputDTO | null = null;
success: port.success,
}; 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', () => { describe('requestAvatarGeneration', () => {
it('should request avatar generation and return 201 on success', async () => { it('should request avatar generation and return 201 on success', async () => {
const input: RequestAvatarGenerationInputDTO = { driverId: 'driver-123' }; const input: RequestAvatarGenerationInputDTO = { driverId: 'driver-123' };
const result = { success: true, jobId: 'job-123' }; const viewModel = { success: true, jobId: 'job-123' } as any;
service.requestAvatarGeneration.mockResolvedValue(result); service.requestAvatarGeneration.mockResolvedValue({ viewModel } as any);
const mockRes: ReturnType<typeof vi.mocked<Response>> = { const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(), status: vi.fn().mockReturnThis(),
@@ -47,13 +47,13 @@ describe('MediaController', () => {
expect(service.requestAvatarGeneration).toHaveBeenCalledWith(input); expect(service.requestAvatarGeneration).toHaveBeenCalledWith(input);
expect(mockRes.status).toHaveBeenCalledWith(201); expect(mockRes.status).toHaveBeenCalledWith(201);
expect(mockRes.json).toHaveBeenCalledWith(result); expect(mockRes.json).toHaveBeenCalledWith(viewModel);
}); });
it('should return 400 on failure', async () => { it('should return 400 on failure', async () => {
const input: RequestAvatarGenerationInputDTO = { driverId: 'driver-123' }; const input: RequestAvatarGenerationInputDTO = { driverId: 'driver-123' };
const result = { success: false, error: 'Error' }; const viewModel = { success: false, error: 'Error' } as any;
service.requestAvatarGeneration.mockResolvedValue(result); service.requestAvatarGeneration.mockResolvedValue({ viewModel } as any);
const mockRes: ReturnType<typeof vi.mocked<Response>> = { const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(), status: vi.fn().mockReturnThis(),
@@ -63,7 +63,7 @@ describe('MediaController', () => {
await controller.requestAvatarGeneration(input, mockRes); await controller.requestAvatarGeneration(input, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(400); 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 () => { it('should upload media and return 201 on success', async () => {
const file: Express.Multer.File = { filename: 'file.jpg' } as Express.Multer.File; const file: Express.Multer.File = { filename: 'file.jpg' } as Express.Multer.File;
const input: UploadMediaInputDTO = { type: 'image' }; const input: UploadMediaInputDTO = { type: 'image' };
const result = { success: true, mediaId: 'media-123' }; const viewModel = { success: true, mediaId: 'media-123' } as any;
service.uploadMedia.mockResolvedValue(result); service.uploadMedia.mockResolvedValue({ viewModel } as any);
const mockRes: ReturnType<typeof vi.mocked<Response>> = { const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(), status: vi.fn().mockReturnThis(),
@@ -83,15 +83,15 @@ describe('MediaController', () => {
expect(service.uploadMedia).toHaveBeenCalledWith({ ...input, file }); expect(service.uploadMedia).toHaveBeenCalledWith({ ...input, file });
expect(mockRes.status).toHaveBeenCalledWith(201); expect(mockRes.status).toHaveBeenCalledWith(201);
expect(mockRes.json).toHaveBeenCalledWith(result); expect(mockRes.json).toHaveBeenCalledWith(viewModel);
}); });
}); });
describe('getMedia', () => { describe('getMedia', () => {
it('should return media if found', async () => { it('should return media if found', async () => {
const mediaId = 'media-123'; const mediaId = 'media-123';
const result = { id: mediaId, url: 'url' }; const viewModel = { id: mediaId, url: 'url' } as any;
service.getMedia.mockResolvedValue(result); service.getMedia.mockResolvedValue({ viewModel } as any);
const mockRes: ReturnType<typeof vi.mocked<Response>> = { const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(), status: vi.fn().mockReturnThis(),
@@ -102,12 +102,12 @@ describe('MediaController', () => {
expect(service.getMedia).toHaveBeenCalledWith(mediaId); expect(service.getMedia).toHaveBeenCalledWith(mediaId);
expect(mockRes.status).toHaveBeenCalledWith(200); expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith(result); expect(mockRes.json).toHaveBeenCalledWith(viewModel);
}); });
it('should return 404 if not found', async () => { it('should return 404 if not found', async () => {
const mediaId = 'media-123'; const mediaId = 'media-123';
service.getMedia.mockResolvedValue(null); service.getMedia.mockResolvedValue({ viewModel: null } as any);
const mockRes: ReturnType<typeof vi.mocked<Response>> = { const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(), status: vi.fn().mockReturnThis(),
@@ -124,8 +124,8 @@ describe('MediaController', () => {
describe('deleteMedia', () => { describe('deleteMedia', () => {
it('should delete media', async () => { it('should delete media', async () => {
const mediaId = 'media-123'; const mediaId = 'media-123';
const result = { success: true }; const viewModel = { success: true } as any;
service.deleteMedia.mockResolvedValue(result); service.deleteMedia.mockResolvedValue({ viewModel } as any);
const mockRes: ReturnType<typeof vi.mocked<Response>> = { const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(), status: vi.fn().mockReturnThis(),
@@ -136,7 +136,7 @@ describe('MediaController', () => {
expect(service.deleteMedia).toHaveBeenCalledWith(mediaId); expect(service.deleteMedia).toHaveBeenCalledWith(mediaId);
expect(mockRes.status).toHaveBeenCalledWith(200); 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, @Body() input: RequestAvatarGenerationInput,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
const result = await this.mediaService.requestAvatarGeneration(input); const presenter = await this.mediaService.requestAvatarGeneration(input);
if (result.success) { const viewModel = presenter.viewModel;
res.status(HttpStatus.CREATED).json(result);
if (viewModel.success) {
res.status(HttpStatus.CREATED).json(viewModel);
} else { } 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, @Body() input: UploadMediaInput,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
const result = await this.mediaService.uploadMedia({ ...input, file }); const presenter = await this.mediaService.uploadMedia({ ...input, file });
if (result.success) { const viewModel = presenter.viewModel;
res.status(HttpStatus.CREATED).json(result);
if (viewModel.success) {
res.status(HttpStatus.CREATED).json(viewModel);
} else { } 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, @Param('mediaId') mediaId: string,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
const result = await this.mediaService.getMedia(mediaId); const presenter = await this.mediaService.getMedia(mediaId);
if (result) { const viewModel = presenter.viewModel;
res.status(HttpStatus.OK).json(result);
if (viewModel) {
res.status(HttpStatus.OK).json(viewModel);
} else { } else {
res.status(HttpStatus.NOT_FOUND).json({ error: 'Media not found' }); res.status(HttpStatus.NOT_FOUND).json({ error: 'Media not found' });
} }
@@ -79,8 +85,10 @@ export class MediaController {
@Param('mediaId') mediaId: string, @Param('mediaId') mediaId: string,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
const result = await this.mediaService.deleteMedia(mediaId); const presenter = await this.mediaService.deleteMedia(mediaId);
res.status(HttpStatus.OK).json(result); const viewModel = presenter.viewModel;
res.status(HttpStatus.OK).json(viewModel);
} }
@Get('avatar/:driverId') @Get('avatar/:driverId')
@@ -91,9 +99,11 @@ export class MediaController {
@Param('driverId') driverId: string, @Param('driverId') driverId: string,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
const result = await this.mediaService.getAvatar(driverId); const presenter = await this.mediaService.getAvatar(driverId);
if (result) { const viewModel = presenter.viewModel;
res.status(HttpStatus.OK).json(result);
if (viewModel) {
res.status(HttpStatus.OK).json(viewModel);
} else { } else {
res.status(HttpStatus.NOT_FOUND).json({ error: 'Avatar not found' }); res.status(HttpStatus.NOT_FOUND).json({ error: 'Avatar not found' });
} }
@@ -108,7 +118,9 @@ export class MediaController {
@Body() input: UpdateAvatarInput, @Body() input: UpdateAvatarInput,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
const result = await this.mediaService.updateAvatar(driverId, input); const presenter = await this.mediaService.updateAvatar(driverId, input);
res.status(HttpStatus.OK).json(result); const viewModel = presenter.viewModel;
res.status(HttpStatus.OK).json(viewModel);
} }
} }

View File

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

View File

@@ -1,4 +1,7 @@
import type { IDeleteMediaPresenter, DeleteMediaResult } from '@core/media/application/presenters/IDeleteMediaPresenter'; import type { IDeleteMediaPresenter, DeleteMediaResult } from '@core/media/application/presenters/IDeleteMediaPresenter';
import type { DeleteMediaOutputDTO } from '../dtos/DeleteMediaOutputDTO';
type DeleteMediaOutput = DeleteMediaOutputDTO;
export class DeleteMediaPresenter implements IDeleteMediaPresenter { export class DeleteMediaPresenter implements IDeleteMediaPresenter {
private result: DeleteMediaResult | null = null; private result: DeleteMediaResult | null = null;
@@ -7,8 +10,12 @@ export class DeleteMediaPresenter implements IDeleteMediaPresenter {
this.result = result; this.result = result;
} }
get viewModel(): DeleteMediaResult { get viewModel(): DeleteMediaOutput {
if (!this.result) throw new Error('Presenter not presented'); 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 { IGetAvatarPresenter, GetAvatarResult } from '@core/media/application/presenters/IGetAvatarPresenter';
import type { GetAvatarOutputDTO } from '../dtos/GetAvatarOutputDTO';
export type GetAvatarViewModel = GetAvatarOutputDTO | null;
export class GetAvatarPresenter implements IGetAvatarPresenter { export class GetAvatarPresenter implements IGetAvatarPresenter {
private result: GetAvatarResult | null = null; private result: GetAvatarResult | null = null;
@@ -7,8 +10,13 @@ export class GetAvatarPresenter implements IGetAvatarPresenter {
this.result = result; this.result = result;
} }
get viewModel(): GetAvatarResult { get viewModel(): GetAvatarViewModel {
if (!this.result) throw new Error('Presenter not presented'); if (!this.result || !this.result.success || !this.result.avatar) {
return this.result; 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 { 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 { export class GetMediaPresenter implements IGetMediaPresenter {
private result: GetMediaResult | null = null; private result: GetMediaResult | null = null;
@@ -7,8 +11,21 @@ export class GetMediaPresenter implements IGetMediaPresenter {
this.result = result; this.result = result;
} }
get viewModel(): GetMediaResult { get viewModel(): GetMediaViewModel {
if (!this.result) throw new Error('Presenter not presented'); if (!this.result || !this.result.success || !this.result.media) {
return this.result; 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,4 +1,7 @@
import type { IUpdateAvatarPresenter, UpdateAvatarResult } from '@core/media/application/presenters/IUpdateAvatarPresenter'; import type { IUpdateAvatarPresenter, UpdateAvatarResult } from '@core/media/application/presenters/IUpdateAvatarPresenter';
import type { UpdateAvatarOutputDTO } from '../dtos/UpdateAvatarOutputDTO';
type UpdateAvatarOutput = UpdateAvatarOutputDTO;
export class UpdateAvatarPresenter implements IUpdateAvatarPresenter { export class UpdateAvatarPresenter implements IUpdateAvatarPresenter {
private result: UpdateAvatarResult | null = null; private result: UpdateAvatarResult | null = null;
@@ -7,8 +10,12 @@ export class UpdateAvatarPresenter implements IUpdateAvatarPresenter {
this.result = result; this.result = result;
} }
get viewModel(): UpdateAvatarResult { get viewModel(): UpdateAvatarOutput {
if (!this.result) throw new Error('Presenter not presented'); 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 { IUploadMediaPresenter, UploadMediaResult } from '@core/media/application/presenters/IUploadMediaPresenter';
import type { UploadMediaOutputDTO } from '../dtos/UploadMediaOutputDTO';
type UploadMediaOutput = UploadMediaOutputDTO;
export class UploadMediaPresenter implements IUploadMediaPresenter { export class UploadMediaPresenter implements IUploadMediaPresenter {
private result: UploadMediaResult | null = null; private result: UploadMediaResult | null = null;
@@ -7,8 +10,20 @@ export class UploadMediaPresenter implements IUploadMediaPresenter {
this.result = result; this.result = result;
} }
get viewModel(): UploadMediaResult { get viewModel(): UploadMediaOutput {
if (!this.result) throw new Error('Presenter not presented'); 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 () => { it('should return payments', async () => {
const query: GetPaymentsQuery = { status: 'pending' }; const query: GetPaymentsQuery = { status: 'pending' };
const result = { payments: [] }; const result = { payments: [] };
service.getPayments.mockResolvedValue(result); service.getPayments.mockResolvedValue({ viewModel: result } as any);
const response = await controller.getPayments(query); const response = await controller.getPayments(query);
@@ -53,7 +53,7 @@ describe('PaymentsController', () => {
it('should create payment', async () => { it('should create payment', async () => {
const input: CreatePaymentInput = { amount: 100, type: 'membership_fee', payerId: 'payer-123', payerType: 'driver', leagueId: 'league-123' }; const input: CreatePaymentInput = { amount: 100, type: 'membership_fee', payerId: 'payer-123', payerType: 'driver', leagueId: 'league-123' };
const result = { payment: { id: 'pay-123' } }; const result = { payment: { id: 'pay-123' } };
service.createPayment.mockResolvedValue(result); service.createPayment.mockResolvedValue({ viewModel: result } as any);
const response = await controller.createPayment(input); const response = await controller.createPayment(input);
@@ -66,7 +66,7 @@ describe('PaymentsController', () => {
it('should update payment status', async () => { it('should update payment status', async () => {
const input: UpdatePaymentStatusInput = { paymentId: 'pay-123', status: 'completed' }; const input: UpdatePaymentStatusInput = { paymentId: 'pay-123', status: 'completed' };
const result = { payment: { id: '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); const response = await controller.updatePaymentStatus(input);
@@ -79,7 +79,7 @@ describe('PaymentsController', () => {
it('should return membership fees', async () => { it('should return membership fees', async () => {
const query: GetMembershipFeesQuery = { leagueId: 'league-123' }; const query: GetMembershipFeesQuery = { leagueId: 'league-123' };
const result = { fees: [] }; const result = { fees: [] };
service.getMembershipFees.mockResolvedValue(result); service.getMembershipFees.mockResolvedValue({ viewModel: result } as any);
const response = await controller.getMembershipFees(query); const response = await controller.getMembershipFees(query);
@@ -92,7 +92,7 @@ describe('PaymentsController', () => {
it('should upsert membership fee', async () => { it('should upsert membership fee', async () => {
const input: UpsertMembershipFeeInput = { leagueId: 'league-123', amount: 50 }; const input: UpsertMembershipFeeInput = { leagueId: 'league-123', amount: 50 };
const result = { feeId: 'fee-123' }; const result = { feeId: 'fee-123' };
service.upsertMembershipFee.mockResolvedValue(result); service.upsertMembershipFee.mockResolvedValue({ viewModel: result } as any);
const response = await controller.upsertMembershipFee(input); const response = await controller.upsertMembershipFee(input);
@@ -105,7 +105,7 @@ describe('PaymentsController', () => {
it('should update member payment', async () => { it('should update member payment', async () => {
const input: UpdateMemberPaymentInput = { memberId: 'member-123', paymentId: 'pay-123' }; const input: UpdateMemberPaymentInput = { memberId: 'member-123', paymentId: 'pay-123' };
const result = { success: true }; const result = { success: true };
service.updateMemberPayment.mockResolvedValue(result); service.updateMemberPayment.mockResolvedValue({ viewModel: result } as any);
const response = await controller.updateMemberPayment(input); const response = await controller.updateMemberPayment(input);
@@ -118,7 +118,7 @@ describe('PaymentsController', () => {
it('should return prizes', async () => { it('should return prizes', async () => {
const query: GetPrizesQuery = { leagueId: 'league-123' }; const query: GetPrizesQuery = { leagueId: 'league-123' };
const result = { prizes: [] }; const result = { prizes: [] };
service.getPrizes.mockResolvedValue(result); service.getPrizes.mockResolvedValue({ viewModel: result } as any);
const response = await controller.getPrizes(query); const response = await controller.getPrizes(query);
@@ -131,7 +131,7 @@ describe('PaymentsController', () => {
it('should create prize', async () => { it('should create prize', async () => {
const input: CreatePrizeInput = { name: 'Prize', amount: 100 }; const input: CreatePrizeInput = { name: 'Prize', amount: 100 };
const result = { prizeId: 'prize-123' }; const result = { prizeId: 'prize-123' };
service.createPrize.mockResolvedValue(result); service.createPrize.mockResolvedValue({ viewModel: result } as any);
const response = await controller.createPrize(input); const response = await controller.createPrize(input);
@@ -144,7 +144,7 @@ describe('PaymentsController', () => {
it('should award prize', async () => { it('should award prize', async () => {
const input: AwardPrizeInput = { prizeId: 'prize-123', driverId: 'driver-123' }; const input: AwardPrizeInput = { prizeId: 'prize-123', driverId: 'driver-123' };
const result = { success: true }; const result = { success: true };
service.awardPrize.mockResolvedValue(result); service.awardPrize.mockResolvedValue({ viewModel: result } as any);
const response = await controller.awardPrize(input); const response = await controller.awardPrize(input);
@@ -157,7 +157,7 @@ describe('PaymentsController', () => {
it('should delete prize', async () => { it('should delete prize', async () => {
const query: DeletePrizeInput = { prizeId: 'prize-123' }; const query: DeletePrizeInput = { prizeId: 'prize-123' };
const result = { success: true }; const result = { success: true };
service.deletePrize.mockResolvedValue(result); service.deletePrize.mockResolvedValue({ viewModel: result } as any);
const response = await controller.deletePrize(query); const response = await controller.deletePrize(query);
@@ -170,7 +170,7 @@ describe('PaymentsController', () => {
it('should return wallet', async () => { it('should return wallet', async () => {
const query: GetWalletQuery = { userId: 'user-123' }; const query: GetWalletQuery = { userId: 'user-123' };
const result = { balance: 100 }; const result = { balance: 100 };
service.getWallet.mockResolvedValue(result); service.getWallet.mockResolvedValue({ viewModel: result } as any);
const response = await controller.getWallet(query); const response = await controller.getWallet(query);
@@ -183,7 +183,7 @@ describe('PaymentsController', () => {
it('should process wallet transaction', async () => { it('should process wallet transaction', async () => {
const input: ProcessWalletTransactionInput = { userId: 'user-123', amount: 50, type: 'deposit' }; const input: ProcessWalletTransactionInput = { userId: 'user-123', amount: 50, type: 'deposit' };
const result = { transactionId: 'tx-123' }; const result = { transactionId: 'tx-123' };
service.processWalletTransaction.mockResolvedValue(result); service.processWalletTransaction.mockResolvedValue({ viewModel: result } as any);
const response = await controller.processWalletTransaction(input); const response = await controller.processWalletTransaction(input);

View File

@@ -12,7 +12,8 @@ export class PaymentsController {
@ApiOperation({ summary: 'Get payments based on filters' }) @ApiOperation({ summary: 'Get payments based on filters' })
@ApiResponse({ status: 200, description: 'List of payments', type: GetPaymentsOutput }) @ApiResponse({ status: 200, description: 'List of payments', type: GetPaymentsOutput })
async getPayments(@Query() query: GetPaymentsQuery): Promise<GetPaymentsOutput> { async getPayments(@Query() query: GetPaymentsQuery): Promise<GetPaymentsOutput> {
return this.paymentsService.getPayments(query); const presenter = await this.paymentsService.getPayments(query);
return presenter.viewModel;
} }
@Post() @Post()
@@ -20,21 +21,24 @@ export class PaymentsController {
@ApiOperation({ summary: 'Create a new payment' }) @ApiOperation({ summary: 'Create a new payment' })
@ApiResponse({ status: 201, description: 'Payment created', type: CreatePaymentOutput }) @ApiResponse({ status: 201, description: 'Payment created', type: CreatePaymentOutput })
async createPayment(@Body() input: CreatePaymentInput): Promise<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') @Patch('status')
@ApiOperation({ summary: 'Update the status of a payment' }) @ApiOperation({ summary: 'Update the status of a payment' })
@ApiResponse({ status: 200, description: 'Payment status updated', type: UpdatePaymentStatusOutput }) @ApiResponse({ status: 200, description: 'Payment status updated', type: UpdatePaymentStatusOutput })
async updatePaymentStatus(@Body() input: UpdatePaymentStatusInput): Promise<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') @Get('membership-fees')
@ApiOperation({ summary: 'Get membership fees and member payments' }) @ApiOperation({ summary: 'Get membership fees and member payments' })
@ApiResponse({ status: 200, description: 'Membership fee configuration and member payments', type: GetMembershipFeesOutput }) @ApiResponse({ status: 200, description: 'Membership fee configuration and member payments', type: GetMembershipFeesOutput })
async getMembershipFees(@Query() query: GetMembershipFeesQuery): Promise<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') @Post('membership-fees')
@@ -42,20 +46,23 @@ export class PaymentsController {
@ApiOperation({ summary: 'Create or update membership fee configuration' }) @ApiOperation({ summary: 'Create or update membership fee configuration' })
@ApiResponse({ status: 201, description: 'Membership fee configuration created or updated', type: UpsertMembershipFeeOutput }) @ApiResponse({ status: 201, description: 'Membership fee configuration created or updated', type: UpsertMembershipFeeOutput })
async upsertMembershipFee(@Body() input: UpsertMembershipFeeInput): Promise<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') @Patch('membership-fees/member-payment')
@ApiOperation({ summary: 'Record or update a member payment' }) @ApiOperation({ summary: 'Record or update a member payment' })
@ApiResponse({ status: 200, description: 'Member payment recorded or updated', type: UpdateMemberPaymentOutput }) @ApiResponse({ status: 200, description: 'Member payment recorded or updated', type: UpdateMemberPaymentOutput })
async updateMemberPayment(@Body() input: UpdateMemberPaymentInput): Promise<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') @Get('prizes')
@ApiOperation({ summary: 'Get prizes for a league or season' }) @ApiOperation({ summary: 'Get prizes for a league or season' })
@ApiResponse({ status: 200, description: 'List of prizes', type: GetPrizesOutput }) @ApiResponse({ status: 200, description: 'List of prizes', type: GetPrizesOutput })
async getPrizes(@Query() query: GetPrizesQuery): Promise<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') @Post('prizes')
@@ -63,27 +70,31 @@ export class PaymentsController {
@ApiOperation({ summary: 'Create a new prize' }) @ApiOperation({ summary: 'Create a new prize' })
@ApiResponse({ status: 201, description: 'Prize created', type: CreatePrizeOutput }) @ApiResponse({ status: 201, description: 'Prize created', type: CreatePrizeOutput })
async createPrize(@Body() input: CreatePrizeInput): Promise<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') @Patch('prizes/award')
@ApiOperation({ summary: 'Award a prize to a driver' }) @ApiOperation({ summary: 'Award a prize to a driver' })
@ApiResponse({ status: 200, description: 'Prize awarded', type: AwardPrizeOutput }) @ApiResponse({ status: 200, description: 'Prize awarded', type: AwardPrizeOutput })
async awardPrize(@Body() input: AwardPrizeInput): Promise<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') @Delete('prizes')
@ApiOperation({ summary: 'Delete a prize' }) @ApiOperation({ summary: 'Delete a prize' })
@ApiResponse({ status: 200, description: 'Prize deleted', type: DeletePrizeOutput }) @ApiResponse({ status: 200, description: 'Prize deleted', type: DeletePrizeOutput })
async deletePrize(@Query() query: DeletePrizeInput): Promise<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') @Get('wallets')
@ApiOperation({ summary: 'Get wallet information and transactions' }) @ApiOperation({ summary: 'Get wallet information and transactions' })
@ApiResponse({ status: 200, description: 'Wallet and transaction data', type: GetWalletOutput }) @ApiResponse({ status: 200, description: 'Wallet and transaction data', type: GetWalletOutput })
async getWallet(@Query() query: GetWalletQuery): Promise<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') @Post('wallets/transactions')
@@ -91,6 +102,7 @@ export class PaymentsController {
@ApiOperation({ summary: 'Process a wallet transaction (deposit or withdrawal)' }) @ApiOperation({ summary: 'Process a wallet transaction (deposit or withdrawal)' })
@ApiResponse({ status: 201, description: 'Wallet transaction processed', type: ProcessWalletTransactionOutput }) @ApiResponse({ status: 201, description: 'Wallet transaction processed', type: ProcessWalletTransactionOutput })
async processWalletTransaction(@Body() input: ProcessWalletTransactionInput): Promise<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, @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 }); this.logger.debug('[PaymentsService] Getting payments', { query });
const presenter = new GetPaymentsPresenter(); const presenter = new GetPaymentsPresenter();
await this.getPaymentsUseCase.execute(query, presenter); 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 }); this.logger.debug('[PaymentsService] Creating payment', { input });
const presenter = new CreatePaymentPresenter(); const presenter = new CreatePaymentPresenter();
await this.createPaymentUseCase.execute(input, presenter); 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 }); this.logger.debug('[PaymentsService] Updating payment status', { input });
const presenter = new UpdatePaymentStatusPresenter(); const presenter = new UpdatePaymentStatusPresenter();
await this.updatePaymentStatusUseCase.execute(input, presenter); 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 }); this.logger.debug('[PaymentsService] Getting membership fees', { query });
const presenter = new GetMembershipFeesPresenter(); const presenter = new GetMembershipFeesPresenter();
await this.getMembershipFeesUseCase.execute(query, presenter); 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 }); this.logger.debug('[PaymentsService] Upserting membership fee', { input });
const presenter = new UpsertMembershipFeePresenter(); const presenter = new UpsertMembershipFeePresenter();
await this.upsertMembershipFeeUseCase.execute(input, presenter); 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 }); this.logger.debug('[PaymentsService] Updating member payment', { input });
const presenter = new UpdateMemberPaymentPresenter(); const presenter = new UpdateMemberPaymentPresenter();
await this.updateMemberPaymentUseCase.execute(input, presenter); 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 }); this.logger.debug('[PaymentsService] Getting prizes', { query });
const presenter = new GetPrizesPresenter(); const presenter = new GetPrizesPresenter();
await this.getPrizesUseCase.execute({ leagueId: query.leagueId!, seasonId: query.seasonId }, presenter); 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 }); this.logger.debug('[PaymentsService] Creating prize', { input });
const presenter = new CreatePrizePresenter(); const presenter = new CreatePrizePresenter();
await this.createPrizeUseCase.execute(input, presenter); 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 }); this.logger.debug('[PaymentsService] Awarding prize', { input });
const presenter = new AwardPrizePresenter(); const presenter = new AwardPrizePresenter();
await this.awardPrizeUseCase.execute(input, presenter); 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 }); this.logger.debug('[PaymentsService] Deleting prize', { input });
const presenter = new DeletePrizePresenter(); const presenter = new DeletePrizePresenter();
await this.deletePrizeUseCase.execute(input, presenter); 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 }); this.logger.debug('[PaymentsService] Getting wallet', { query });
const presenter = new GetWalletPresenter(); const presenter = new GetWalletPresenter();
await this.getWalletUseCase.execute({ leagueId: query.leagueId! }, presenter); 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 }); this.logger.debug('[PaymentsService] Processing wallet transaction', { input });
const presenter = new ProcessWalletTransactionPresenter(); const presenter = new ProcessWalletTransactionPresenter();
await this.processWalletTransactionUseCase.execute(input, presenter); await this.processWalletTransactionUseCase.execute(input, presenter);
return presenter.viewModel; return presenter;
} }
} }

View File

@@ -1,19 +1,21 @@
import { Test, TestingModule } from '@nestjs/testing'; 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 { ProtestsController } from './ProtestsController';
import { RaceService } from '../race/RaceService'; import { ProtestsService } from './ProtestsService';
import { ReviewProtestCommandDTO } from '../race/dtos/ReviewProtestCommandDTO'; import { ReviewProtestCommandDTO } from '../race/dtos/ReviewProtestCommandDTO';
import type { ReviewProtestPresenter } from './presenters/ReviewProtestPresenter';
describe('ProtestsController', () => { describe('ProtestsController', () => {
let controller: ProtestsController; let controller: ProtestsController;
let raceService: ReturnType<typeof vi.mocked<RaceService>>; let reviewProtestMock: MockedFunction<ProtestsService['reviewProtest']>;
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [ProtestsController], controllers: [ProtestsController],
providers: [ providers: [
{ {
provide: RaceService, provide: ProtestsService,
useValue: { useValue: {
reviewProtest: vi.fn(), reviewProtest: vi.fn(),
}, },
@@ -22,18 +24,98 @@ describe('ProtestsController', () => {
}).compile(); }).compile();
controller = module.get<ProtestsController>(ProtestsController); 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', () => { describe('reviewProtest', () => {
it('should review protest', async () => { it('should call service and not throw on success', async () => {
const protestId = 'protest-123'; const protestId = 'protest-123';
const body: Omit<ReviewProtestCommandDTO, 'protestId'> = { decision: 'upheld', reason: 'Reason' }; const body: Omit<ReviewProtestCommandDTO, 'protestId'> = {
raceService.reviewProtest.mockResolvedValue(undefined); stewardId: 'steward-1',
decision: 'uphold',
decisionNotes: 'Reason',
};
reviewProtestMock.mockResolvedValue(
successPresenter({
success: true,
protestId,
stewardId: body.stewardId,
decision: body.decision,
}),
);
await controller.reviewProtest(protestId, body); 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 { Body, Controller, ForbiddenException, HttpCode, HttpStatus, InternalServerErrorException, NotFoundException, Param, Post } from '@nestjs/common';
import { ApiTags, ApiResponse, ApiOperation, ApiParam } from '@nestjs/swagger'; import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { RaceService } from '../race/RaceService'; import { ProtestsService } from './ProtestsService';
import { ReviewProtestCommandDTO } from '../race/dtos/ReviewProtestCommandDTO'; import { ReviewProtestCommandDTO } from '../race/dtos/ReviewProtestCommandDTO';
@ApiTags('protests') @ApiTags('protests')
@Controller('protests') @Controller('protests')
export class ProtestsController { export class ProtestsController {
constructor(private readonly raceService: RaceService) {} constructor(private readonly protestsService: ProtestsService) {}
@Post(':protestId/review') @Post(':protestId/review')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@@ -17,6 +17,20 @@ export class ProtestsController {
@Param('protestId') protestId: string, @Param('protestId') protestId: string,
@Body() body: Omit<ReviewProtestCommandDTO, 'protestId'>, @Body() body: Omit<ReviewProtestCommandDTO, 'protestId'>,
): Promise<void> { ): 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'; import type { Logger } from '@core/shared/application/Logger';
// Use cases // Use cases
import { ReviewProtestUseCase } from '@core/racing/application/use-cases/ReviewProtestUseCase'; import { ReviewProtestUseCase } from '@core/racing/application/use-cases/ReviewProtestUseCase';
// Presenter
import { ReviewProtestPresenter } from './presenters/ReviewProtestPresenter';
// Tokens // Tokens
import { LOGGER_TOKEN } from './ProtestsProviders'; import { LOGGER_TOKEN } from './ProtestsProviders';
@@ -19,13 +22,41 @@ export class ProtestsService {
stewardId: string; stewardId: string;
decision: 'uphold' | 'dismiss'; decision: 'uphold' | 'dismiss';
decisionNotes: string; decisionNotes: string;
}): Promise<void> { }): Promise<ReviewProtestPresenter> {
this.logger.debug('[ProtestsService] Reviewing protest:', command); this.logger.debug('[ProtestsService] Reviewing protest:', command);
const presenter = new ReviewProtestPresenter();
const result = await this.reviewProtestUseCase.execute(command); const result = await this.reviewProtestUseCase.execute(command);
if (result.isErr()) { 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(), applyQuickPenalty: jest.fn(),
applyPenalty: jest.fn(), applyPenalty: jest.fn(),
requestProtestDefense: jest.fn(), requestProtestDefense: jest.fn(),
}; } as unknown as jest.Mocked<RaceService>;
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [RaceController], controllers: [RaceController],
@@ -39,7 +39,7 @@ describe('RaceController', () => {
}).compile(); }).compile();
controller = module.get<RaceController>(RaceController); controller = module.get<RaceController>(RaceController);
service = module.get(RaceService); service = module.get(RaceService) as jest.Mocked<RaceService>;
}); });
it('should be defined', () => { it('should be defined', () => {
@@ -47,28 +47,26 @@ describe('RaceController', () => {
}); });
describe('getAllRaces', () => { describe('getAllRaces', () => {
it('should return all races', async () => { it('should return all races view model', async () => {
const mockResult = { races: [], totalCount: 0 }; const mockViewModel = { races: [], filters: { statuses: [], leagues: [] } } as { races: unknown[]; filters: { statuses: unknown[]; leagues: unknown[] } };
service.getAllRaces.mockResolvedValue(mockResult); service.getAllRaces.mockResolvedValue({ viewModel: mockViewModel } as unknown as ReturnType<RaceService['getAllRaces']>);
const result = await controller.getAllRaces(); const result = await controller.getAllRaces();
expect(service.getAllRaces).toHaveBeenCalled(); expect(service.getAllRaces).toHaveBeenCalled();
expect(result).toEqual(mockResult); expect(result).toEqual(mockViewModel);
}); });
}); });
describe('getTotalRaces', () => { describe('getTotalRaces', () => {
it('should return total races count', async () => { it('should return total races count view model', async () => {
const mockResult = { totalRaces: 5 }; const mockViewModel = { totalRaces: 5 } as { totalRaces: number };
service.getTotalRaces.mockResolvedValue(mockResult); service.getTotalRaces.mockResolvedValue({ viewModel: mockViewModel } as unknown as ReturnType<RaceService['getTotalRaces']>);
const result = await controller.getTotalRaces(); const result = await controller.getTotalRaces();
expect(service.getTotalRaces).toHaveBeenCalled(); 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 { ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { RaceService } from './RaceService'; import { RaceService } from './RaceService';
import { AllRacesPageDTO } from './dtos/AllRacesPageDTO'; import { AllRacesPageDTO } from './dtos/AllRacesPageDTO';
@@ -27,28 +27,32 @@ export class RaceController {
@ApiOperation({ summary: 'Get all races' }) @ApiOperation({ summary: 'Get all races' })
@ApiResponse({ status: 200, description: 'List of all races', type: AllRacesPageDTO }) @ApiResponse({ status: 200, description: 'List of all races', type: AllRacesPageDTO })
async getAllRaces(): Promise<AllRacesPageDTO> { async getAllRaces(): Promise<AllRacesPageDTO> {
return this.raceService.getAllRaces(); const presenter = await this.raceService.getAllRaces();
return presenter.viewModel;
} }
@Get('total-races') @Get('total-races')
@ApiOperation({ summary: 'Get the total number of races' }) @ApiOperation({ summary: 'Get the total number of races' })
@ApiResponse({ status: 200, description: 'Total number of races', type: RaceStatsDTO }) @ApiResponse({ status: 200, description: 'Total number of races', type: RaceStatsDTO })
async getTotalRaces(): Promise<RaceStatsDTO> { async getTotalRaces(): Promise<RaceStatsDTO> {
return this.raceService.getTotalRaces(); const presenter = await this.raceService.getTotalRaces();
return presenter.viewModel;
} }
@Get('page-data') @Get('page-data')
@ApiOperation({ summary: 'Get races page data' }) @ApiOperation({ summary: 'Get races page data' })
@ApiResponse({ status: 200, description: 'Races page data', type: RacesPageDataDTO }) @ApiResponse({ status: 200, description: 'Races page data', type: RacesPageDataDTO })
async getRacesPageData(): Promise<RacesPageDataDTO> { async getRacesPageData(): Promise<RacesPageDataDTO> {
return this.raceService.getRacesPageData(); const presenter = await this.raceService.getRacesPageData();
return presenter.viewModel;
} }
@Get('all/page-data') @Get('all/page-data')
@ApiOperation({ summary: 'Get all races page data' }) @ApiOperation({ summary: 'Get all races page data' })
@ApiResponse({ status: 200, description: 'All races page data', type: AllRacesPageDTO }) @ApiResponse({ status: 200, description: 'All races page data', type: AllRacesPageDTO })
async getAllRacesPageData(): Promise<AllRacesPageDTO> { async getAllRacesPageData(): Promise<AllRacesPageDTO> {
return this.raceService.getAllRacesPageData(); const presenter = await this.raceService.getAllRacesPageData();
return presenter.viewModel;
} }
@Get(':raceId') @Get(':raceId')
@@ -60,7 +64,8 @@ export class RaceController {
@Param('raceId') raceId: string, @Param('raceId') raceId: string,
@Query('driverId') driverId: string, @Query('driverId') driverId: string,
): Promise<RaceDetailDTO> { ): Promise<RaceDetailDTO> {
return this.raceService.getRaceDetail({ raceId, driverId }); const presenter = await this.raceService.getRaceDetail({ raceId, driverId });
return presenter.viewModel;
} }
@Get(':raceId/results') @Get(':raceId/results')
@@ -68,7 +73,8 @@ export class RaceController {
@ApiParam({ name: 'raceId', description: 'Race ID' }) @ApiParam({ name: 'raceId', description: 'Race ID' })
@ApiResponse({ status: 200, description: 'Race results detail', type: RaceResultsDetailDTO }) @ApiResponse({ status: 200, description: 'Race results detail', type: RaceResultsDetailDTO })
async getRaceResultsDetail(@Param('raceId') raceId: string): Promise<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') @Get(':raceId/sof')
@@ -76,7 +82,8 @@ export class RaceController {
@ApiParam({ name: 'raceId', description: 'Race ID' }) @ApiParam({ name: 'raceId', description: 'Race ID' })
@ApiResponse({ status: 200, description: 'Race with SOF', type: RaceWithSOFDTO }) @ApiResponse({ status: 200, description: 'Race with SOF', type: RaceWithSOFDTO })
async getRaceWithSOF(@Param('raceId') raceId: string): Promise<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') @Get(':raceId/protests')
@@ -84,7 +91,8 @@ export class RaceController {
@ApiParam({ name: 'raceId', description: 'Race ID' }) @ApiParam({ name: 'raceId', description: 'Race ID' })
@ApiResponse({ status: 200, description: 'Race protests', type: RaceProtestsDTO }) @ApiResponse({ status: 200, description: 'Race protests', type: RaceProtestsDTO })
async getRaceProtests(@Param('raceId') raceId: string): Promise<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') @Get(':raceId/penalties')
@@ -92,7 +100,8 @@ export class RaceController {
@ApiParam({ name: 'raceId', description: 'Race ID' }) @ApiParam({ name: 'raceId', description: 'Race ID' })
@ApiResponse({ status: 200, description: 'Race penalties', type: RacePenaltiesDTO }) @ApiResponse({ status: 200, description: 'Race penalties', type: RacePenaltiesDTO })
async getRacePenalties(@Param('raceId') raceId: string): Promise<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') @Post(':raceId/register')
@@ -104,7 +113,12 @@ export class RaceController {
@Param('raceId') raceId: string, @Param('raceId') raceId: string,
@Body() body: Omit<RegisterForRaceParamsDTO, 'raceId'>, @Body() body: Omit<RegisterForRaceParamsDTO, 'raceId'>,
): Promise<void> { ): 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') @Post(':raceId/withdraw')
@@ -116,7 +130,12 @@ export class RaceController {
@Param('raceId') raceId: string, @Param('raceId') raceId: string,
@Body() body: Omit<WithdrawFromRaceParamsDTO, 'raceId'>, @Body() body: Omit<WithdrawFromRaceParamsDTO, 'raceId'>,
): Promise<void> { ): 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') @Post(':raceId/cancel')
@@ -125,7 +144,12 @@ export class RaceController {
@ApiParam({ name: 'raceId', description: 'Race ID' }) @ApiParam({ name: 'raceId', description: 'Race ID' })
@ApiResponse({ status: 200, description: 'Successfully cancelled race' }) @ApiResponse({ status: 200, description: 'Successfully cancelled race' })
async cancelRace(@Param('raceId') raceId: string): Promise<void> { 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') @Post(':raceId/complete')
@@ -134,7 +158,12 @@ export class RaceController {
@ApiParam({ name: 'raceId', description: 'Race ID' }) @ApiParam({ name: 'raceId', description: 'Race ID' })
@ApiResponse({ status: 200, description: 'Successfully completed race' }) @ApiResponse({ status: 200, description: 'Successfully completed race' })
async completeRace(@Param('raceId') raceId: string): Promise<void> { 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') @Post(':raceId/reopen')
@@ -143,7 +172,12 @@ export class RaceController {
@ApiParam({ name: 'raceId', description: 'Race ID' }) @ApiParam({ name: 'raceId', description: 'Race ID' })
@ApiResponse({ status: 200, description: 'Successfully re-opened race' }) @ApiResponse({ status: 200, description: 'Successfully re-opened race' })
async reopenRace(@Param('raceId') raceId: string): Promise<void> { 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') @Post(':raceId/import-results')
@@ -155,7 +189,8 @@ export class RaceController {
@Param('raceId') raceId: string, @Param('raceId') raceId: string,
@Body() body: Omit<ImportRaceResultsDTO, 'raceId'>, @Body() body: Omit<ImportRaceResultsDTO, 'raceId'>,
): Promise<ImportRaceResultsSummaryDTO> { ): Promise<ImportRaceResultsSummaryDTO> {
return this.raceService.importRaceResults({ raceId, ...body }); const presenter = await this.raceService.importRaceResults({ raceId, ...body });
return presenter.viewModel;
} }
@Post('protests/file') @Post('protests/file')
@@ -163,7 +198,12 @@ export class RaceController {
@ApiOperation({ summary: 'File a protest' }) @ApiOperation({ summary: 'File a protest' })
@ApiResponse({ status: 200, description: 'Protest filed successfully' }) @ApiResponse({ status: 200, description: 'Protest filed successfully' })
async fileProtest(@Body() body: FileProtestCommandDTO): Promise<void> { 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') @Post('penalties/quick')
@@ -171,7 +211,12 @@ export class RaceController {
@ApiOperation({ summary: 'Apply a quick penalty' }) @ApiOperation({ summary: 'Apply a quick penalty' })
@ApiResponse({ status: 200, description: 'Penalty applied successfully' }) @ApiResponse({ status: 200, description: 'Penalty applied successfully' })
async applyQuickPenalty(@Body() body: QuickPenaltyCommandDTO): Promise<void> { 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') @Post('penalties/apply')
@@ -179,7 +224,12 @@ export class RaceController {
@ApiOperation({ summary: 'Apply a penalty' }) @ApiOperation({ summary: 'Apply a penalty' })
@ApiResponse({ status: 200, description: 'Penalty applied successfully' }) @ApiResponse({ status: 200, description: 'Penalty applied successfully' })
async applyPenalty(@Body() body: ApplyPenaltyCommandDTO): Promise<void> { 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') @Post('protests/defense/request')
@@ -187,6 +237,11 @@ export class RaceController {
@ApiOperation({ summary: 'Request protest defense' }) @ApiOperation({ summary: 'Request protest defense' })
@ApiResponse({ status: 200, description: 'Defense requested successfully' }) @ApiResponse({ status: 200, description: 'Defense requested successfully' })
async requestProtestDefense(@Body() body: RequestProtestDefenseCommandDTO): Promise<void> { 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 { IPenaltyRepository } from '@core/racing/domain/repositories/IPenaltyRepository';
import type { IProtestRepository } from '@core/racing/domain/repositories/IProtestRepository'; import type { IProtestRepository } from '@core/racing/domain/repositories/IProtestRepository';
import type { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider'; import type { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider';
import type { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
// Import concrete in-memory implementations // Import concrete in-memory implementations
import { InMemoryRaceRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRepository'; 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 { Inject, Injectable } from '@nestjs/common';
import type { AllRacesPageViewModel } from '@core/racing/application/presenters/IGetAllRacesPresenter';
import type { RaceDetailOutputPort } from '@core/racing/application/ports/output/RaceDetailOutputPort'; import type { RaceDetailOutputPort } from '@core/racing/application/ports/output/RaceDetailOutputPort';
import type { RacesPageOutputPort } from '@core/racing/application/ports/output/RacesPageOutputPort'; import type { RacesPageOutputPort } from '@core/racing/application/ports/output/RacesPageOutputPort';
import type { RaceResultsDetailOutputPort } from '@core/racing/application/ports/output/RaceResultsDetailOutputPort'; 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 { WithdrawFromRaceParamsDTO } from './dtos/WithdrawFromRaceParamsDTO';
import { RaceActionParamsDTO } from './dtos/RaceActionParamsDTO'; import { RaceActionParamsDTO } from './dtos/RaceActionParamsDTO';
import { ImportRaceResultsDTO } from './dtos/ImportRaceResultsDTO'; 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 // Core imports
import type { Logger } from '@core/shared/application/Logger'; import type { Logger } from '@core/shared/application/Logger';
import { Result } from '@core/shared/application/Result';
import { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider'; 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'; import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
// Use cases // 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 { WithdrawFromRaceUseCase } from '@core/racing/application/use-cases/WithdrawFromRaceUseCase';
import { CancelRaceUseCase } from '@core/racing/application/use-cases/CancelRaceUseCase'; import { CancelRaceUseCase } from '@core/racing/application/use-cases/CancelRaceUseCase';
import { CompleteRaceUseCase } from '@core/racing/application/use-cases/CompleteRaceUseCase'; 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 { FileProtestUseCase } from '@core/racing/application/use-cases/FileProtestUseCase';
import { QuickPenaltyUseCase } from '@core/racing/application/use-cases/QuickPenaltyUseCase'; import { QuickPenaltyUseCase } from '@core/racing/application/use-cases/QuickPenaltyUseCase';
import { ApplyPenaltyUseCase } from '@core/racing/application/use-cases/ApplyPenaltyUseCase'; 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 { GetAllRacesPresenter } from './presenters/GetAllRacesPresenter';
import { GetTotalRacesPresenter } from './presenters/GetTotalRacesPresenter'; import { GetTotalRacesPresenter } from './presenters/GetTotalRacesPresenter';
import { ImportRaceResultsApiPresenter } from './presenters/ImportRaceResultsApiPresenter'; 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 // Command DTOs
import { FileProtestCommandDTO } from './dtos/FileProtestCommandDTO'; import { FileProtestCommandDTO } from './dtos/FileProtestCommandDTO';
@@ -93,15 +93,21 @@ export class RaceService {
@Inject(IMAGE_SERVICE_TOKEN) private readonly imageService: IImageServicePort, @Inject(IMAGE_SERVICE_TOKEN) private readonly imageService: IImageServicePort,
) {} ) {}
async getAllRaces(): Promise<AllRacesPageViewModel> { async getAllRaces(): Promise<GetAllRacesPresenter> {
this.logger.debug('[RaceService] Fetching all races.'); 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(); const presenter = new GetAllRacesPresenter();
await this.getAllRacesUseCase.execute({}, presenter); await presenter.present(result.unwrap());
return presenter.getViewModel()!; return presenter;
} }
async getTotalRaces(): Promise<RaceStatsDTO> { async getTotalRaces(): Promise<GetTotalRacesPresenter> {
this.logger.debug('[RaceService] Fetching total races count.'); this.logger.debug('[RaceService] Fetching total races count.');
const result = await this.getTotalRacesUseCase.execute(); const result = await this.getTotalRacesUseCase.execute();
if (result.isErr()) { if (result.isErr()) {
@@ -109,10 +115,10 @@ export class RaceService {
} }
const presenter = new GetTotalRacesPresenter(); const presenter = new GetTotalRacesPresenter();
presenter.present(result.unwrap()); 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); this.logger.debug('Importing race results:', input);
const result = await this.importRaceResultsApiUseCase.execute({ raceId: input.raceId, resultsFileContent: input.resultsFileContent }); const result = await this.importRaceResultsApiUseCase.execute({ raceId: input.raceId, resultsFileContent: input.resultsFileContent });
if (result.isErr()) { if (result.isErr()) {
@@ -120,10 +126,10 @@ export class RaceService {
} }
const presenter = new ImportRaceResultsApiPresenter(); const presenter = new ImportRaceResultsApiPresenter();
presenter.present(result.unwrap()); 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); this.logger.debug('[RaceService] Fetching race detail:', params);
const result = await this.getRaceDetailUseCase.execute(params); const result = await this.getRaceDetailUseCase.execute(params);
@@ -132,79 +138,12 @@ export class RaceService {
throw new Error('Failed to get race detail'); throw new Error('Failed to get race detail');
} }
const outputPort = result.value as RaceDetailOutputPort; const presenter = new RaceDetailPresenter(this.driverRatingProvider, this.imageService);
await presenter.present(result.value as RaceDetailOutputPort, params);
// Map to DTO return presenter;
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,
};
} }
async getRacesPageData(): Promise<RacesPageDataDTO> { async getRacesPageData(): Promise<RacesPageDataPresenter> {
this.logger.debug('[RaceService] Fetching races page data.'); this.logger.debug('[RaceService] Fetching races page data.');
const result = await this.getRacesPageDataUseCase.execute(); const result = await this.getRacesPageDataUseCase.execute();
@@ -213,33 +152,12 @@ export class RaceService {
throw new Error('Failed to get races page data'); throw new Error('Failed to get races page data');
} }
const outputPort = result.value as RacesPageOutputPort; const presenter = new RacesPageDataPresenter(this.leagueRepository);
await presenter.present(result.value as RacesPageOutputPort);
// Fetch leagues for league names return presenter;
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,
};
} }
async getAllRacesPageData(): Promise<AllRacesPageDTO> { async getAllRacesPageData(): Promise<AllRacesPageDataPresenter> {
this.logger.debug('[RaceService] Fetching all races page data.'); this.logger.debug('[RaceService] Fetching all races page data.');
const result = await this.getAllRacesPageDataUseCase.execute(); const result = await this.getAllRacesPageDataUseCase.execute();
@@ -248,10 +166,12 @@ export class RaceService {
throw new Error('Failed to get all races page data'); 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 }); this.logger.debug('[RaceService] Fetching race results detail:', { raceId });
const result = await this.getRaceResultsDetailUseCase.execute({ raceId }); const result = await this.getRaceResultsDetailUseCase.execute({ raceId });
@@ -260,43 +180,12 @@ export class RaceService {
throw new Error('Failed to get race results detail'); throw new Error('Failed to get race results detail');
} }
const outputPort = result.value as RaceResultsDetailOutputPort; const presenter = new RaceResultsDetailPresenter(this.imageService);
await presenter.present(result.value as RaceResultsDetailOutputPort);
// Create a map of driverId to driver for easy lookup return presenter;
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,
};
} }
async getRaceWithSOF(raceId: string): Promise<RaceWithSOFDTO> { async getRaceWithSOF(raceId: string): Promise<RaceWithSOFPresenter> {
this.logger.debug('[RaceService] Fetching race with SOF:', { raceId }); this.logger.debug('[RaceService] Fetching race with SOF:', { raceId });
const result = await this.getRaceWithSOFUseCase.execute({ raceId }); const result = await this.getRaceWithSOFUseCase.execute({ raceId });
@@ -305,17 +194,12 @@ export class RaceService {
throw new Error('Failed to get race with SOF'); throw new Error('Failed to get race with SOF');
} }
const outputPort = result.value as RaceWithSOFOutputPort; const presenter = new RaceWithSOFPresenter();
presenter.present(result.value as RaceWithSOFOutputPort);
// Map to DTO return presenter;
return {
id: outputPort.id,
track: outputPort.track,
strengthOfField: outputPort.strengthOfField,
};
} }
async getRaceProtests(raceId: string): Promise<RaceProtestsDTO> { async getRaceProtests(raceId: string): Promise<RaceProtestsPresenter> {
this.logger.debug('[RaceService] Fetching race protests:', { raceId }); this.logger.debug('[RaceService] Fetching race protests:', { raceId });
const result = await this.getRaceProtestsUseCase.execute({ raceId }); const result = await this.getRaceProtestsUseCase.execute({ raceId });
@@ -324,32 +208,12 @@ export class RaceService {
throw new Error('Failed to get race protests'); throw new Error('Failed to get race protests');
} }
const outputPort = result.value as RaceProtestsOutputPort; const presenter = new RaceProtestsPresenter();
presenter.present(result.value as RaceProtestsOutputPort);
const protestsDTO = outputPort.protests.map(protest => ({ return presenter;
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,
};
} }
async getRacePenalties(raceId: string): Promise<RacePenaltiesDTO> { async getRacePenalties(raceId: string): Promise<RacePenaltiesPresenter> {
this.logger.debug('[RaceService] Fetching race penalties:', { raceId }); this.logger.debug('[RaceService] Fetching race penalties:', { raceId });
const result = await this.getRacePenaltiesUseCase.execute({ raceId }); const result = await this.getRacePenaltiesUseCase.execute({ raceId });
@@ -358,148 +222,175 @@ export class RaceService {
throw new Error('Failed to get race penalties'); throw new Error('Failed to get race penalties');
} }
const outputPort = result.value as RacePenaltiesOutputPort; const presenter = new RacePenaltiesPresenter();
presenter.present(result.value as RacePenaltiesOutputPort);
const penaltiesDTO = outputPort.penalties.map(penalty => ({ return presenter;
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,
};
} }
async registerForRace(params: RegisterForRaceParamsDTO): Promise<void> { async registerForRace(params: RegisterForRaceParamsDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Registering for race:', params); this.logger.debug('[RaceService] Registering for race:', params);
const result = await this.registerForRaceUseCase.execute(params); const result = await this.registerForRaceUseCase.execute(params);
const presenter = new CommandResultPresenter();
if (result.isErr()) { 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); this.logger.debug('[RaceService] Withdrawing from race:', params);
const result = await this.withdrawFromRaceUseCase.execute(params); const result = await this.withdrawFromRaceUseCase.execute(params);
const presenter = new CommandResultPresenter();
if (result.isErr()) { 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); this.logger.debug('[RaceService] Cancelling race:', params);
const result = await this.cancelRaceUseCase.execute({ raceId: params.raceId }); const result = await this.cancelRaceUseCase.execute({ raceId: params.raceId });
const presenter = new CommandResultPresenter();
if (result.isErr()) { 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); this.logger.debug('[RaceService] Completing race:', params);
const result = await this.completeRaceUseCase.execute({ raceId: params.raceId }); const result = await this.completeRaceUseCase.execute({ raceId: params.raceId });
const presenter = new CommandResultPresenter();
if (result.isErr()) { 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); this.logger.debug('[RaceService] Re-opening race:', params);
const result = await this.reopenRaceUseCase.execute({ raceId: params.raceId }); const result = await this.reopenRaceUseCase.execute({ raceId: params.raceId });
const presenter = new CommandResultPresenter();
if (result.isErr()) { if (result.isErr()) {
const errorCode = result.unwrapErr().code; 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') { if (errorCode === 'RACE_ALREADY_SCHEDULED') {
this.logger.debug('[RaceService] Race is already scheduled, treating reopen as success.'); 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); this.logger.debug('[RaceService] Filing protest:', command);
const result = await this.fileProtestUseCase.execute(command); const result = await this.fileProtestUseCase.execute(command);
const presenter = new CommandResultPresenter();
if (result.isErr()) { 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); this.logger.debug('[RaceService] Applying quick penalty:', command);
const result = await this.quickPenaltyUseCase.execute(command); const result = await this.quickPenaltyUseCase.execute(command);
const presenter = new CommandResultPresenter();
if (result.isErr()) { 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); this.logger.debug('[RaceService] Applying penalty:', command);
const result = await this.applyPenaltyUseCase.execute(command); const result = await this.applyPenaltyUseCase.execute(command);
const presenter = new CommandResultPresenter();
if (result.isErr()) { 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); this.logger.debug('[RaceService] Requesting protest defense:', command);
const result = await this.requestProtestDefenseUseCase.execute(command); const result = await this.requestProtestDefenseUseCase.execute(command);
const presenter = new CommandResultPresenter();
if (result.isErr()) { 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); this.logger.debug('[RaceService] Reviewing protest:', command);
const result = await this.reviewProtestUseCase.execute(command); const result = await this.reviewProtestUseCase.execute(command);
const presenter = new CommandResultPresenter();
if (result.isErr()) { 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 { presenter.presentSuccess();
const baseChange = position <= 3 ? 25 : position <= 10 ? 10 : -5; return presenter;
const positionBonus = Math.max(0, (20 - position) * 2);
return baseChange + positionBonus;
} }
} }

View File

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

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; 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 { export class FileProtestCommandDTO {
@ApiProperty() @ApiProperty()

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsBoolean, IsNumber } from 'class-validator'; import { IsString, IsBoolean } from 'class-validator';
export class RaceDetailEntryDTO { export class RaceDetailEntryDTO {
@ApiProperty() @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(), getPendingSponsorshipRequests: vi.fn(),
acceptSponsorshipRequest: vi.fn(), acceptSponsorshipRequest: vi.fn(),
rejectSponsorshipRequest: 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', () => { describe('getEntitySponsorshipPricing', () => {
it('should return sponsorship pricing', async () => { it('should return sponsorship pricing', async () => {
const mockResult = { entityType: 'season', entityId: 'season-1', pricing: [] }; const mockResult = { entityType: 'season', entityId: 'season-1', pricing: [] };
sponsorService.getEntitySponsorshipPricing.mockResolvedValue(mockResult); sponsorService.getEntitySponsorshipPricing.mockResolvedValue({ viewModel: mockResult } as any);
const result = await controller.getEntitySponsorshipPricing(); const result = await controller.getEntitySponsorshipPricing();
@@ -47,7 +52,7 @@ describe('SponsorController', () => {
describe('getSponsors', () => { describe('getSponsors', () => {
it('should return sponsors list', async () => { it('should return sponsors list', async () => {
const mockResult = { sponsors: [] }; const mockResult = { sponsors: [] };
sponsorService.getSponsors.mockResolvedValue(mockResult); sponsorService.getSponsors.mockResolvedValue({ viewModel: mockResult } as any);
const result = await controller.getSponsors(); const result = await controller.getSponsors();
@@ -59,10 +64,10 @@ describe('SponsorController', () => {
describe('createSponsor', () => { describe('createSponsor', () => {
it('should create sponsor', async () => { it('should create sponsor', async () => {
const input = { name: 'Test Sponsor', contactEmail: 'test@example.com' }; const input = { name: 'Test Sponsor', contactEmail: 'test@example.com' };
const mockResult = { id: 'sponsor-1', name: 'Test Sponsor' }; const mockResult = { sponsor: { id: 's1', name: 'Test Sponsor' } };
sponsorService.createSponsor.mockResolvedValue(mockResult); 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(result).toEqual(mockResult);
expect(sponsorService.createSponsor).toHaveBeenCalledWith(input); expect(sponsorService.createSponsor).toHaveBeenCalledWith(input);
@@ -71,9 +76,9 @@ describe('SponsorController', () => {
describe('getSponsorDashboard', () => { describe('getSponsorDashboard', () => {
it('should return sponsor dashboard', async () => { it('should return sponsor dashboard', async () => {
const sponsorId = 'sponsor-1'; const sponsorId = 's1';
const mockResult = { sponsorId, metrics: {}, sponsoredLeagues: [] }; const mockResult = { sponsorId, metrics: {} as any, sponsoredLeagues: [], investment: {} as any };
sponsorService.getSponsorDashboard.mockResolvedValue(mockResult); sponsorService.getSponsorDashboard.mockResolvedValue({ viewModel: mockResult } as any);
const result = await controller.getSponsorDashboard(sponsorId); const result = await controller.getSponsorDashboard(sponsorId);
@@ -82,8 +87,8 @@ describe('SponsorController', () => {
}); });
it('should return null when sponsor not found', async () => { it('should return null when sponsor not found', async () => {
const sponsorId = 'sponsor-1'; const sponsorId = 's1';
sponsorService.getSponsorDashboard.mockResolvedValue(null); sponsorService.getSponsorDashboard.mockResolvedValue({ viewModel: null } as any);
const result = await controller.getSponsorDashboard(sponsorId); const result = await controller.getSponsorDashboard(sponsorId);
@@ -93,9 +98,20 @@ describe('SponsorController', () => {
describe('getSponsorSponsorships', () => { describe('getSponsorSponsorships', () => {
it('should return sponsor sponsorships', async () => { it('should return sponsor sponsorships', async () => {
const sponsorId = 'sponsor-1'; const sponsorId = 's1';
const mockResult = { sponsorId, sponsorships: [] }; const mockResult = {
sponsorService.getSponsorSponsorships.mockResolvedValue(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); const result = await controller.getSponsorSponsorships(sponsorId);
@@ -104,8 +120,8 @@ describe('SponsorController', () => {
}); });
it('should return null when sponsor not found', async () => { it('should return null when sponsor not found', async () => {
const sponsorId = 'sponsor-1'; const sponsorId = 's1';
sponsorService.getSponsorSponsorships.mockResolvedValue(null); sponsorService.getSponsorSponsorships.mockResolvedValue({ viewModel: null } as any);
const result = await controller.getSponsorSponsorships(sponsorId); const result = await controller.getSponsorSponsorships(sponsorId);
@@ -115,9 +131,9 @@ describe('SponsorController', () => {
describe('getSponsor', () => { describe('getSponsor', () => {
it('should return sponsor', async () => { it('should return sponsor', async () => {
const sponsorId = 'sponsor-1'; const sponsorId = 's1';
const mockResult = { id: sponsorId, name: 'Test Sponsor' }; const mockResult = { sponsor: { id: sponsorId, name: 'S1' } };
sponsorService.getSponsor.mockResolvedValue(mockResult); sponsorService.getSponsor.mockResolvedValue({ viewModel: mockResult } as any);
const result = await controller.getSponsor(sponsorId); const result = await controller.getSponsor(sponsorId);
@@ -126,8 +142,8 @@ describe('SponsorController', () => {
}); });
it('should return null when sponsor not found', async () => { it('should return null when sponsor not found', async () => {
const sponsorId = 'sponsor-1'; const sponsorId = 's1';
sponsorService.getSponsor.mockResolvedValue(null); sponsorService.getSponsor.mockResolvedValue({ viewModel: null } as any);
const result = await controller.getSponsor(sponsorId); const result = await controller.getSponsor(sponsorId);
@@ -138,8 +154,13 @@ describe('SponsorController', () => {
describe('getPendingSponsorshipRequests', () => { describe('getPendingSponsorshipRequests', () => {
it('should return pending sponsorship requests', async () => { it('should return pending sponsorship requests', async () => {
const query = { entityType: 'season' as const, entityId: 'season-1' }; const query = { entityType: 'season' as const, entityId: 'season-1' };
const mockResult = { entityType: 'season', entityId: 'season-1', requests: [], totalCount: 0 }; const mockResult = {
sponsorService.getPendingSponsorshipRequests.mockResolvedValue(mockResult); entityType: 'season',
entityId: 'season-1',
requests: [],
totalCount: 0,
};
sponsorService.getPendingSponsorshipRequests.mockResolvedValue({ viewModel: mockResult } as any);
const result = await controller.getPendingSponsorshipRequests(query); const result = await controller.getPendingSponsorshipRequests(query);
@@ -150,30 +171,33 @@ describe('SponsorController', () => {
describe('acceptSponsorshipRequest', () => { describe('acceptSponsorshipRequest', () => {
it('should accept sponsorship request', async () => { it('should accept sponsorship request', async () => {
const requestId = 'request-1'; const requestId = 'r1';
const input = { respondedBy: 'user-1' }; const input = { respondedBy: 'u1' };
const mockResult = { const mockResult = {
requestId, requestId,
sponsorshipId: 'sponsorship-1', sponsorshipId: 'sp1',
status: 'accepted' as const, status: 'accepted' as const,
acceptedAt: new Date(), acceptedAt: new Date(),
platformFee: 10, platformFee: 10,
netAmount: 90, 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(result).toEqual(mockResult);
expect(sponsorService.acceptSponsorshipRequest).toHaveBeenCalledWith(requestId, input.respondedBy); expect(sponsorService.acceptSponsorshipRequest).toHaveBeenCalledWith(
requestId,
input.respondedBy,
);
}); });
it('should return null on error', async () => { it('should return null on error', async () => {
const requestId = 'request-1'; const requestId = 'r1';
const input = { respondedBy: 'user-1' }; const input = { respondedBy: 'u1' };
sponsorService.acceptSponsorshipRequest.mockResolvedValue(null); 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(); expect(result).toBeNull();
}); });
@@ -181,30 +205,118 @@ describe('SponsorController', () => {
describe('rejectSponsorshipRequest', () => { describe('rejectSponsorshipRequest', () => {
it('should reject sponsorship request', async () => { it('should reject sponsorship request', async () => {
const requestId = 'request-1'; const requestId = 'r1';
const input = { respondedBy: 'user-1', reason: 'Not interested' }; const input = { respondedBy: 'u1', reason: 'Not interested' };
const mockResult = { const mockResult = {
requestId, requestId,
status: 'rejected' as const, status: 'rejected' as const,
rejectedAt: new Date(), rejectedAt: new Date(),
reason: 'Not interested', 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(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 () => { it('should return null on error', async () => {
const requestId = 'request-1'; const requestId = 'r1';
const input = { respondedBy: 'user-1' }; const input = { respondedBy: 'u1' };
sponsorService.rejectSponsorshipRequest.mockResolvedValue(null); 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(); 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 { SponsorProfileDTO } from './dtos/SponsorProfileDTO';
import { NotificationSettingsDTO } from './dtos/NotificationSettingsDTO'; import { NotificationSettingsDTO } from './dtos/NotificationSettingsDTO';
import { PrivacySettingsDTO } from './dtos/PrivacySettingsDTO'; 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'; import type { RejectSponsorshipRequestResultDTO } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
@ApiTags('sponsors') @ApiTags('sponsors')
@@ -33,129 +33,212 @@ export class SponsorController {
@Get('pricing') @Get('pricing')
@ApiOperation({ summary: 'Get sponsorship pricing for an entity' }) @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> { async getEntitySponsorshipPricing(): Promise<GetEntitySponsorshipPricingResultDTO> {
return this.sponsorService.getEntitySponsorshipPricing(); const presenter = await this.sponsorService.getEntitySponsorshipPricing();
return presenter.viewModel;
} }
@Get() @Get()
@ApiOperation({ summary: 'Get all sponsors' }) @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> { async getSponsors(): Promise<GetSponsorsOutputDTO> {
return this.sponsorService.getSponsors(); const presenter = await this.sponsorService.getSponsors();
return presenter.viewModel;
} }
@Post() @Post()
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a new sponsor' }) @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> { 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') @Get('dashboard/:sponsorId')
@ApiOperation({ summary: 'Get sponsor dashboard metrics and sponsored leagues' }) @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' }) @ApiResponse({ status: 404, description: 'Sponsor not found' })
async getSponsorDashboard(@Param('sponsorId') sponsorId: string): Promise<SponsorDashboardDTO | null> { async getSponsorDashboard(
return this.sponsorService.getSponsorDashboard({ sponsorId } as GetSponsorDashboardQueryParamsDTO); @Param('sponsorId') sponsorId: string,
): Promise<SponsorDashboardDTO | null> {
const presenter = await this.sponsorService.getSponsorDashboard({
sponsorId,
} as GetSponsorDashboardQueryParamsDTO);
return presenter.viewModel;
} }
@Get(':sponsorId/sponsorships') @Get(':sponsorId/sponsorships')
@ApiOperation({ summary: 'Get all sponsorships for a given sponsor' }) @ApiOperation({
@ApiResponse({ status: 200, description: 'List of sponsorships', type: SponsorSponsorshipsDTO }) summary: 'Get all sponsorships for a given sponsor',
})
@ApiResponse({
status: 200,
description: 'List of sponsorships',
type: SponsorSponsorshipsDTO,
})
@ApiResponse({ status: 404, description: 'Sponsor not found' }) @ApiResponse({ status: 404, description: 'Sponsor not found' })
async getSponsorSponsorships(@Param('sponsorId') sponsorId: string): Promise<SponsorSponsorshipsDTO | null> { async getSponsorSponsorships(
return this.sponsorService.getSponsorSponsorships({ sponsorId } as GetSponsorSponsorshipsQueryParamsDTO); @Param('sponsorId') sponsorId: string,
): Promise<SponsorSponsorshipsDTO | null> {
const presenter = await this.sponsorService.getSponsorSponsorships({
sponsorId,
} as GetSponsorSponsorshipsQueryParamsDTO);
return presenter.viewModel;
} }
@Get(':sponsorId') @Get(':sponsorId')
@ApiOperation({ summary: 'Get a sponsor by ID' }) @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' }) @ApiResponse({ status: 404, description: 'Sponsor not found' })
async getSponsor(@Param('sponsorId') sponsorId: string): Promise<GetSponsorOutputDTO | null> { 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') @Get('requests')
@ApiOperation({ summary: 'Get pending sponsorship requests' }) @ApiOperation({ summary: 'Get pending sponsorship requests' })
@ApiResponse({ status: 200, description: 'List of pending sponsorship requests', type: GetPendingSponsorshipRequestsOutputDTO }) @ApiResponse({
async getPendingSponsorshipRequests(@Query() query: { entityType: string; entityId: string }): Promise<GetPendingSponsorshipRequestsOutputDTO> { status: 200,
return this.sponsorService.getPendingSponsorshipRequests(query as { entityType: import('@core/racing/domain/entities/SponsorshipRequest').SponsorableEntityType; entityId: string }); 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') @Post('requests/:requestId/accept')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Accept a sponsorship request' }) @ApiOperation({ summary: 'Accept a sponsorship request' })
@ApiResponse({ status: 200, description: 'Sponsorship request accepted' }) @ApiResponse({ status: 200, description: 'Sponsorship request accepted' })
@ApiResponse({ status: 400, description: 'Invalid request' }) @ApiResponse({ status: 400, description: 'Invalid request' })
@ApiResponse({ status: 404, description: 'Request not found' }) @ApiResponse({ status: 404, description: 'Request not found' })
async acceptSponsorshipRequest(@Param('requestId') requestId: string, @Body() input: AcceptSponsorshipRequestInputDTO): Promise<AcceptSponsorshipRequestResultPort | null> { async acceptSponsorshipRequest(
return this.sponsorService.acceptSponsorshipRequest(requestId, input.respondedBy); @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') @Post('requests/:requestId/reject')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Reject a sponsorship request' }) @ApiOperation({ summary: 'Reject a sponsorship request' })
@ApiResponse({ status: 200, description: 'Sponsorship request rejected' }) @ApiResponse({ status: 200, description: 'Sponsorship request rejected' })
@ApiResponse({ status: 400, description: 'Invalid request' }) @ApiResponse({ status: 400, description: 'Invalid request' })
@ApiResponse({ status: 404, description: 'Request not found' }) @ApiResponse({ status: 404, description: 'Request not found' })
async rejectSponsorshipRequest(@Param('requestId') requestId: string, @Body() input: RejectSponsorshipRequestInputDTO): Promise<RejectSponsorshipRequestResultDTO | null> { async rejectSponsorshipRequest(
return this.sponsorService.rejectSponsorshipRequest(requestId, input.respondedBy, input.reason); @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') @Get('billing/:sponsorId')
@ApiOperation({ summary: 'Get sponsor billing information' }) @ApiOperation({ summary: 'Get sponsor billing information' })
@ApiResponse({ status: 200, description: 'Sponsor billing data', type: Object }) @ApiResponse({ status: 200, description: 'Sponsor billing data', type: Object })
async getSponsorBilling(@Param('sponsorId') sponsorId: string): Promise<{ async getSponsorBilling(
paymentMethods: PaymentMethodDTO[]; @Param('sponsorId') sponsorId: string,
invoices: InvoiceDTO[]; ): Promise<{
stats: BillingStatsDTO; paymentMethods: PaymentMethodDTO[];
}> { invoices: InvoiceDTO[];
return this.sponsorService.getSponsorBilling(sponsorId); stats: BillingStatsDTO;
} }> {
const presenter = await this.sponsorService.getSponsorBilling(sponsorId);
return presenter.viewModel;
}
@Get('leagues/available') @Get('leagues/available')
@ApiOperation({ summary: 'Get available leagues for sponsorship' }) @ApiOperation({ summary: 'Get available leagues for sponsorship' })
@ApiResponse({ status: 200, description: 'Available leagues', type: [AvailableLeagueDTO] }) @ApiResponse({
async getAvailableLeagues(): Promise<AvailableLeagueDTO[]> { status: 200,
return this.sponsorService.getAvailableLeagues(); description: 'Available leagues',
} type: [AvailableLeagueDTO],
})
async getAvailableLeagues(): Promise<AvailableLeagueDTO[] | null> {
const presenter = await this.sponsorService.getAvailableLeagues();
return presenter.viewModel;
}
@Get('leagues/:leagueId/detail') @Get('leagues/:leagueId/detail')
@ApiOperation({ summary: 'Get detailed league information for sponsors' }) @ApiOperation({ summary: 'Get detailed league information for sponsors' })
@ApiResponse({ status: 200, description: 'League detail data', type: Object }) @ApiResponse({ status: 200, description: 'League detail data', type: Object })
async getLeagueDetail(@Param('leagueId') leagueId: string): Promise<{ async getLeagueDetail(
league: LeagueDetailDTO; @Param('leagueId') leagueId: string,
drivers: DriverDTO[]; ): Promise<{
races: RaceDTO[]; league: LeagueDetailDTO;
}> { drivers: DriverDTO[];
return this.sponsorService.getLeagueDetail(leagueId); races: RaceDTO[];
} } | null> {
const presenter = await this.sponsorService.getLeagueDetail(leagueId);
return presenter.viewModel;
}
@Get('settings/:sponsorId') @Get('settings/:sponsorId')
@ApiOperation({ summary: 'Get sponsor settings' }) @ApiOperation({ summary: 'Get sponsor settings' })
@ApiResponse({ status: 200, description: 'Sponsor settings', type: Object }) @ApiResponse({ status: 200, description: 'Sponsor settings', type: Object })
async getSponsorSettings(@Param('sponsorId') sponsorId: string): Promise<{ async getSponsorSettings(
profile: SponsorProfileDTO; @Param('sponsorId') sponsorId: string,
notifications: NotificationSettingsDTO; ): Promise<{
privacy: PrivacySettingsDTO; profile: SponsorProfileDTO;
}> { notifications: NotificationSettingsDTO;
return this.sponsorService.getSponsorSettings(sponsorId); privacy: PrivacySettingsDTO;
} } | null> {
const presenter = await this.sponsorService.getSponsorSettings(sponsorId);
return presenter.viewModel;
}
@Put('settings/:sponsorId') @Put('settings/:sponsorId')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Update sponsor settings' }) @ApiOperation({ summary: 'Update sponsor settings' })
@ApiResponse({ status: 200, description: 'Settings updated successfully' }) @ApiResponse({ status: 200, description: 'Settings updated successfully' })
async updateSponsorSettings( async updateSponsorSettings(
@Param('sponsorId') sponsorId: string, @Param('sponsorId') sponsorId: string,
@Body() input: { @Body()
profile?: Partial<SponsorProfileDTO>; input: {
notifications?: Partial<NotificationSettingsDTO>; profile?: Partial<SponsorProfileDTO>;
privacy?: Partial<PrivacySettingsDTO>; notifications?: Partial<NotificationSettingsDTO>;
} privacy?: Partial<PrivacySettingsDTO>;
): Promise<void> { },
return this.sponsorService.updateSponsorSettings(sponsorId, input); ): 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 { 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 { GetSponsorshipPricingUseCase } from '@core/racing/application/use-cases/GetSponsorshipPricingUseCase';
import type { GetSponsorsUseCase } from '@core/racing/application/use-cases/GetSponsorsUseCase'; import type { GetSponsorsUseCase } from '@core/racing/application/use-cases/GetSponsorsUseCase';
import type { CreateSponsorUseCase } from '@core/racing/application/use-cases/CreateSponsorUseCase'; 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 { GetPendingSponsorshipRequestsUseCase } from '@core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
import type { AcceptSponsorshipRequestUseCase } from '@core/racing/application/use-cases/AcceptSponsorshipRequestUseCase'; import type { AcceptSponsorshipRequestUseCase } from '@core/racing/application/use-cases/AcceptSponsorshipRequestUseCase';
import type { RejectSponsorshipRequestUseCase } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase'; import type { RejectSponsorshipRequestUseCase } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
import type { Logger } from '@core/shared/application'; import { SponsorService } from './SponsorService';
import { Result } from '@core/shared/application/Result';
describe('SponsorService', () => { describe('SponsorService', () => {
let service: SponsorService; let service: SponsorService;
@@ -23,12 +23,7 @@ describe('SponsorService', () => {
let getPendingSponsorshipRequestsUseCase: { execute: Mock }; let getPendingSponsorshipRequestsUseCase: { execute: Mock };
let acceptSponsorshipRequestUseCase: { execute: Mock }; let acceptSponsorshipRequestUseCase: { execute: Mock };
let rejectSponsorshipRequestUseCase: { execute: Mock }; let rejectSponsorshipRequestUseCase: { execute: Mock };
let logger: { let logger: Logger;
debug: Mock;
info: Mock;
warn: Mock;
error: Mock;
};
beforeEach(() => { beforeEach(() => {
getSponsorshipPricingUseCase = { execute: vi.fn() }; getSponsorshipPricingUseCase = { execute: vi.fn() };
@@ -45,7 +40,7 @@ describe('SponsorService', () => {
info: vi.fn(), info: vi.fn(),
warn: vi.fn(), warn: vi.fn(),
error: vi.fn(), error: vi.fn(),
}; } as unknown as Logger;
service = new SponsorService( service = new SponsorService(
getSponsorshipPricingUseCase as unknown as GetSponsorshipPricingUseCase, getSponsorshipPricingUseCase as unknown as GetSponsorshipPricingUseCase,
@@ -57,136 +52,199 @@ describe('SponsorService', () => {
getPendingSponsorshipRequestsUseCase as unknown as GetPendingSponsorshipRequestsUseCase, getPendingSponsorshipRequestsUseCase as unknown as GetPendingSponsorshipRequestsUseCase,
acceptSponsorshipRequestUseCase as unknown as AcceptSponsorshipRequestUseCase, acceptSponsorshipRequestUseCase as unknown as AcceptSponsorshipRequestUseCase,
rejectSponsorshipRequestUseCase as unknown as RejectSponsorshipRequestUseCase, rejectSponsorshipRequestUseCase as unknown as RejectSponsorshipRequestUseCase,
logger as unknown as Logger, logger,
); );
}); });
describe('getEntitySponsorshipPricing', () => { describe('getEntitySponsorshipPricing', () => {
it('should return sponsorship pricing', async () => { it('returns presenter with pricing data on success', async () => {
const mockPresenter = { const outputPort = {
viewModel: { entityType: 'season', entityId: 'season-1', pricing: [] }, 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 presenter = await service.getEntitySponsorshipPricing();
const originalGetSponsorshipPricingPresenter = await import('./presenters/GetSponsorshipPricingPresenter');
const mockPresenterClass = vi.fn().mockImplementation(() => mockPresenter);
vi.doMock('./presenters/GetSponsorshipPricingPresenter', () => ({
GetSponsorshipPricingPresenter: mockPresenterClass,
}));
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); it('returns empty pricing on error', async () => {
expect(getSponsorshipPricingUseCase.execute).toHaveBeenCalledWith(undefined, mockPresenter); getSponsorshipPricingUseCase.execute.mockResolvedValue(Result.err({ code: 'REPOSITORY_ERROR' }));
const presenter = await service.getEntitySponsorshipPricing();
expect(presenter.viewModel).toEqual({
entityType: 'season',
entityId: '',
pricing: [],
});
}); });
}); });
describe('getSponsors', () => { describe('getSponsors', () => {
it('should return sponsors list', async () => { it('returns sponsors in presenter on success', async () => {
const mockPresenter = { const outputPort = { sponsors: [{ id: 's1', name: 'S1', contactEmail: 's1@test', createdAt: new Date() }] };
viewModel: { sponsors: [] }, getSponsorsUseCase.execute.mockResolvedValue(Result.ok(outputPort));
};
getSponsorsUseCase.execute.mockResolvedValue(undefined);
const result = await service.getSponsors(); const presenter = await service.getSponsors();
expect(result).toEqual(mockPresenter.viewModel); expect(presenter.viewModel).toEqual({ sponsors: outputPort.sponsors });
expect(getSponsorsUseCase.execute).toHaveBeenCalledWith(undefined, expect.any(Object)); });
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', () => { describe('createSponsor', () => {
it('should create sponsor successfully', async () => { it('returns created sponsor in presenter on success', async () => {
const input = { name: 'Test Sponsor', contactEmail: 'test@example.com' }; const input = { name: 'Test', contactEmail: 'test@example.com' };
const mockPresenter = { const outputPort = {
viewModel: { id: 'sponsor-1', name: 'Test Sponsor' }, 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(presenter.viewModel).toEqual({ sponsor: outputPort.sponsor });
expect(createSponsorUseCase.execute).toHaveBeenCalledWith(input, expect.any(Object)); });
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', () => { describe('getSponsorDashboard', () => {
it('should return sponsor dashboard', async () => { it('returns dashboard in presenter on success', async () => {
const params = { sponsorId: 'sponsor-1' }; const params = { sponsorId: 's1' };
const mockPresenter = { const outputPort = {
viewModel: { sponsorId: 'sponsor-1', metrics: {}, sponsoredLeagues: [] }, 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(presenter.viewModel).toEqual(outputPort);
expect(getSponsorDashboardUseCase.execute).toHaveBeenCalledWith(params, expect.any(Object)); });
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', () => { describe('getSponsorSponsorships', () => {
it('should return sponsor sponsorships', async () => { it('returns sponsorships in presenter on success', async () => {
const params = { sponsorId: 'sponsor-1' }; const params = { sponsorId: 's1' };
const mockPresenter = { const outputPort = {
viewModel: { sponsorId: 'sponsor-1', sponsorships: [] }, 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(presenter.viewModel).toEqual(outputPort);
expect(getSponsorSponsorshipsUseCase.execute).toHaveBeenCalledWith(params, expect.any(Object)); });
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', () => { describe('getSponsor', () => {
it('should return sponsor when found', async () => { it('returns sponsor in presenter when found', async () => {
const sponsorId = 'sponsor-1'; const sponsorId = 's1';
const mockSponsor = { id: sponsorId, name: 'Test Sponsor' }; const output = { sponsor: { id: sponsorId, name: 'S1' } };
getSponsorUseCase.execute.mockResolvedValue(Result.ok(mockSponsor)); getSponsorUseCase.execute.mockResolvedValue(Result.ok(output));
const result = await service.getSponsor(sponsorId); const presenter = await service.getSponsor(sponsorId);
expect(result).toEqual(mockSponsor); expect(presenter.viewModel).toEqual({ sponsor: output.sponsor });
expect(getSponsorUseCase.execute).toHaveBeenCalledWith({ sponsorId });
}); });
it('should return null when sponsor not found', async () => { it('returns null in presenter when not found', async () => {
const sponsorId = 'sponsor-1'; const sponsorId = 's1';
getSponsorUseCase.execute.mockResolvedValue(Result.err({ code: 'NOT_FOUND' })); 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', () => { 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 params = { entityType: 'season' as const, entityId: 'season-1' };
const mockResult = { const outputPort = {
entityType: 'season', entityType: 'season',
entityId: 'season-1', entityId: 'season-1',
requests: [], requests: [],
totalCount: 0, 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(presenter.viewModel).toEqual(outputPort);
expect(getPendingSponsorshipRequestsUseCase.execute).toHaveBeenCalledWith(params);
}); });
it('should return empty result on error', async () => { it('returns empty result on error', async () => {
const params = { entityType: 'season' as const, entityId: 'season-1' }; 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', entityType: 'season',
entityId: 'season-1', entityId: 'season-1',
requests: [], requests: [],
@@ -196,63 +254,113 @@ describe('SponsorService', () => {
}); });
describe('acceptSponsorshipRequest', () => { describe('acceptSponsorshipRequest', () => {
it('should accept sponsorship request successfully', async () => { it('returns accept result in presenter on success', async () => {
const requestId = 'request-1'; const requestId = 'r1';
const respondedBy = 'user-1'; const respondedBy = 'u1';
const mockResult = { const outputPort = {
requestId, requestId,
sponsorshipId: 'sponsorship-1', sponsorshipId: 'sp1',
status: 'accepted' as const, status: 'accepted' as const,
acceptedAt: new Date(), acceptedAt: new Date(),
platformFee: 10, platformFee: 10,
netAmount: 90, 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(presenter.viewModel).toEqual(outputPort);
expect(acceptSponsorshipRequestUseCase.execute).toHaveBeenCalledWith({ requestId, respondedBy });
}); });
it('should return null on error', async () => { it('returns null in presenter on error', async () => {
const requestId = 'request-1'; const requestId = 'r1';
const respondedBy = 'user-1'; const respondedBy = 'u1';
acceptSponsorshipRequestUseCase.execute.mockResolvedValue(Result.err({ code: 'NOT_FOUND' })); 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', () => { describe('rejectSponsorshipRequest', () => {
it('should reject sponsorship request successfully', async () => { it('returns reject result in presenter on success', async () => {
const requestId = 'request-1'; const requestId = 'r1';
const respondedBy = 'user-1'; const respondedBy = 'u1';
const reason = 'Not interested'; const reason = 'Not interested';
const mockResult = { const output = {
requestId, requestId,
status: 'rejected' as const, status: 'rejected' as const,
rejectedAt: new Date(), rejectedAt: new Date(),
reason, 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(presenter.viewModel).toEqual(output);
expect(rejectSponsorshipRequestUseCase.execute).toHaveBeenCalledWith({ requestId, respondedBy, reason });
}); });
it('should return null on error', async () => { it('returns null in presenter on error', async () => {
const requestId = 'request-1'; const requestId = 'r1';
const respondedBy = 'user-1'; const respondedBy = 'u1';
rejectSponsorshipRequestUseCase.execute.mockResolvedValue(Result.err({ code: 'NOT_FOUND' })); 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 { Injectable, Inject } from '@nestjs/common';
import { GetEntitySponsorshipPricingResultDTO } from './dtos/GetEntitySponsorshipPricingResultDTO';
import { GetSponsorsOutputDTO } from './dtos/GetSponsorsOutputDTO';
import { CreateSponsorInputDTO } from './dtos/CreateSponsorInputDTO'; import { CreateSponsorInputDTO } from './dtos/CreateSponsorInputDTO';
import { CreateSponsorOutputDTO } from './dtos/CreateSponsorOutputDTO';
import { GetSponsorDashboardQueryParamsDTO } from './dtos/GetSponsorDashboardQueryParamsDTO'; import { GetSponsorDashboardQueryParamsDTO } from './dtos/GetSponsorDashboardQueryParamsDTO';
import { SponsorDashboardDTO } from './dtos/SponsorDashboardDTO';
import { GetSponsorSponsorshipsQueryParamsDTO } from './dtos/GetSponsorSponsorshipsQueryParamsDTO'; 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 { AcceptSponsorshipRequestInputDTO } from './dtos/AcceptSponsorshipRequestInputDTO';
import { RejectSponsorshipRequestInputDTO } from './dtos/RejectSponsorshipRequestInputDTO'; import { RejectSponsorshipRequestInputDTO } from './dtos/RejectSponsorshipRequestInputDTO';
import { PaymentMethodDTO } from './dtos/PaymentMethodDTO'; 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 { GetSponsorDashboardUseCase } from '@core/racing/application/use-cases/GetSponsorDashboardUseCase';
import { GetSponsorSponsorshipsUseCase } from '@core/racing/application/use-cases/GetSponsorSponsorshipsUseCase'; import { GetSponsorSponsorshipsUseCase } from '@core/racing/application/use-cases/GetSponsorSponsorshipsUseCase';
import { GetSponsorUseCase } from '@core/racing/application/use-cases/GetSponsorUseCase'; 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 { AcceptSponsorshipRequestUseCase } from '@core/racing/application/use-cases/AcceptSponsorshipRequestUseCase';
import { RejectSponsorshipRequestUseCase } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase'; import { RejectSponsorshipRequestUseCase } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
import type { SponsorableEntityType } from '@core/racing/domain/entities/SponsorshipRequest'; import type { SponsorableEntityType } from '@core/racing/domain/entities/SponsorshipRequest';
import type { AcceptSponsorshipRequestResultPort } from '@core/racing/application/ports/output/AcceptSponsorshipRequestResultPort'; import type { Logger } from '@core/shared/application';
import type { RejectSponsorshipRequestResultDTO } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
// 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 // 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 {
import type { Logger } from '@core/shared/application'; 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() @Injectable()
export class SponsorService { export class SponsorService {
constructor( constructor(
@Inject(GET_SPONSORSHIP_PRICING_USE_CASE_TOKEN) private readonly getSponsorshipPricingUseCase: GetSponsorshipPricingUseCase, @Inject(GET_SPONSORSHIP_PRICING_USE_CASE_TOKEN)
@Inject(GET_SPONSORS_USE_CASE_TOKEN) private readonly getSponsorsUseCase: GetSponsorsUseCase, private readonly getSponsorshipPricingUseCase: GetSponsorshipPricingUseCase,
@Inject(CREATE_SPONSOR_USE_CASE_TOKEN) private readonly createSponsorUseCase: CreateSponsorUseCase, @Inject(GET_SPONSORS_USE_CASE_TOKEN)
@Inject(GET_SPONSOR_DASHBOARD_USE_CASE_TOKEN) private readonly getSponsorDashboardUseCase: GetSponsorDashboardUseCase, private readonly getSponsorsUseCase: GetSponsorsUseCase,
@Inject(GET_SPONSOR_SPONSORSHIPS_USE_CASE_TOKEN) private readonly getSponsorSponsorshipsUseCase: GetSponsorSponsorshipsUseCase, @Inject(CREATE_SPONSOR_USE_CASE_TOKEN)
@Inject(GET_SPONSOR_USE_CASE_TOKEN) private readonly getSponsorUseCase: GetSponsorUseCase, private readonly createSponsorUseCase: CreateSponsorUseCase,
@Inject(GET_PENDING_SPONSORSHIP_REQUESTS_USE_CASE_TOKEN) private readonly getPendingSponsorshipRequestsUseCase: GetPendingSponsorshipRequestsUseCase, @Inject(GET_SPONSOR_DASHBOARD_USE_CASE_TOKEN)
@Inject(ACCEPT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN) private readonly acceptSponsorshipRequestUseCase: AcceptSponsorshipRequestUseCase, private readonly getSponsorDashboardUseCase: GetSponsorDashboardUseCase,
@Inject(REJECT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN) private readonly rejectSponsorshipRequestUseCase: RejectSponsorshipRequestUseCase, @Inject(GET_SPONSOR_SPONSORSHIPS_USE_CASE_TOKEN)
@Inject(LOGGER_TOKEN) private readonly logger: Logger, 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.'); this.logger.debug('[SponsorService] Fetching sponsorship pricing.');
const presenter = new GetEntitySponsorshipPricingPresenter();
const result = await this.getSponsorshipPricingUseCase.execute(); const result = await this.getSponsorshipPricingUseCase.execute();
if (result.isErr()) { if (result.isErr()) {
this.logger.error('[SponsorService] Failed to fetch sponsorship pricing.', result.error); 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.'); this.logger.debug('[SponsorService] Fetching sponsors.');
const presenter = new GetSponsorsPresenter();
const result = await this.getSponsorsUseCase.execute(); const result = await this.getSponsorsUseCase.execute();
if (result.isErr()) { if (result.isErr()) {
this.logger.error('[SponsorService] Failed to fetch sponsors.', result.error); 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 }); this.logger.debug('[SponsorService] Creating sponsor.', { input });
const presenter = new CreateSponsorPresenter();
const result = await this.createSponsorUseCase.execute(input); const result = await this.createSponsorUseCase.execute(input);
if (result.isErr()) { if (result.isErr()) {
this.logger.error('[SponsorService] Failed to create sponsor.', result.error); this.logger.error('[SponsorService] Failed to create sponsor.', result.error);
throw new Error(result.error.details?.message || 'Failed to create sponsor'); 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 }); this.logger.debug('[SponsorService] Fetching sponsor dashboard.', { params });
const presenter = new GetSponsorDashboardPresenter();
const result = await this.getSponsorDashboardUseCase.execute(params); const result = await this.getSponsorDashboardUseCase.execute(params);
if (result.isErr()) { if (result.isErr()) {
this.logger.error('[SponsorService] Failed to fetch sponsor dashboard.', result.error); 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 }); this.logger.debug('[SponsorService] Fetching sponsor sponsorships.', { params });
const presenter = new GetSponsorSponsorshipsPresenter();
const result = await this.getSponsorSponsorshipsUseCase.execute(params); const result = await this.getSponsorSponsorshipsUseCase.execute(params);
if (result.isErr()) { if (result.isErr()) {
this.logger.error('[SponsorService] Failed to fetch sponsor sponsorships.', result.error); 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 }); this.logger.debug('[SponsorService] Fetching sponsor.', { sponsorId });
const presenter = new GetSponsorPresenter();
const result = await this.getSponsorUseCase.execute({ sponsorId }); const result = await this.getSponsorUseCase.execute({ sponsorId });
if (result.isErr()) { if (result.isErr()) {
this.logger.error('[SponsorService] Failed to fetch sponsor.', result.error); 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 }); 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()) { if (result.isErr()) {
this.logger.error('[SponsorService] Failed to fetch pending sponsorship requests.', result.error); 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> { async acceptSponsorshipRequest(
this.logger.debug('[SponsorService] Accepting sponsorship request.', { requestId, respondedBy }); 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()) { if (result.isErr()) {
this.logger.error('[SponsorService] Failed to accept sponsorship request.', result.error); 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> { async rejectSponsorshipRequest(
this.logger.debug('[SponsorService] Rejecting sponsorship request.', { requestId, respondedBy, reason }); 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()) { if (result.isErr()) {
this.logger.error('[SponsorService] Failed to reject sponsorship request.', result.error); 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<{ async getSponsorBilling(sponsorId: string): Promise<SponsorBillingPresenter> {
paymentMethods: PaymentMethodDTO[];
invoices: InvoiceDTO[];
stats: BillingStatsDTO;
}> {
this.logger.debug('[SponsorService] Fetching sponsor billing.', { sponsorId }); this.logger.debug('[SponsorService] Fetching sponsor billing.', { sponsorId });
const presenter = new SponsorBillingPresenter();
// Mock data - in real implementation, this would come from repositories // Mock data - in real implementation, this would come from repositories
const paymentMethods: PaymentMethodDTO[] = [ const paymentMethods: PaymentMethodDTO[] = [
{ {
@@ -242,14 +349,16 @@ export class SponsorService {
averageMonthlySpend: 2075, 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.'); this.logger.debug('[SponsorService] Fetching available leagues.');
// Mock data const presenter = new AvailableLeaguesPresenter();
return [
const leagues: AvailableLeagueDTO[] = [
{ {
id: 'league-1', id: 'league-1',
name: 'GT3 Masters Championship', name: 'GT3 Masters Championship',
@@ -262,7 +371,8 @@ export class SponsorService {
tier: 'premium', tier: 'premium',
nextRace: '2025-12-20', nextRace: '2025-12-20',
seasonStatus: 'active', 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', id: 'league-2',
@@ -276,18 +386,20 @@ export class SponsorService {
tier: 'premium', tier: 'premium',
nextRace: '2026-01-05', nextRace: '2026-01-05',
seasonStatus: 'active', 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<{ async getLeagueDetail(leagueId: string): Promise<LeagueDetailPresenter> {
league: LeagueDetailDTO;
drivers: DriverDTO[];
races: RaceDTO[];
}> {
this.logger.debug('[SponsorService] Fetching league detail.', { leagueId }); this.logger.debug('[SponsorService] Fetching league detail.', { leagueId });
const presenter = new LeagueDetailPresenter();
// Mock data // Mock data
const league: LeagueDetailDTO = { const league: LeagueDetailDTO = {
id: leagueId, id: leagueId,
@@ -295,7 +407,8 @@ export class SponsorService {
game: 'iRacing', game: 'iRacing',
tier: 'premium', tier: 'premium',
season: 'Season 3', 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, drivers: 48,
races: 12, races: 12,
completedRaces: 8, completedRaces: 8,
@@ -316,7 +429,7 @@ export class SponsorService {
'Race results page branding', 'Race results page branding',
'Social media feature posts', 'Social media feature posts',
'Newsletter sponsor spot', 'Newsletter sponsor spot',
] ],
}, },
secondary: { secondary: {
available: 1, available: 1,
@@ -327,31 +440,58 @@ export class SponsorService {
'League page sidebar placement', 'League page sidebar placement',
'Race results mention', 'Race results mention',
'Social media mentions', 'Social media mentions',
] ],
}, },
}, },
}; };
const drivers: DriverDTO[] = [ 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[] = [ 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<{ async getSponsorSettings(sponsorId: string): Promise<SponsorSettingsPresenter> {
profile: SponsorProfileDTO;
notifications: NotificationSettingsDTO;
privacy: PrivacySettingsDTO;
}> {
this.logger.debug('[SponsorService] Fetching sponsor settings.', { sponsorId }); this.logger.debug('[SponsorService] Fetching sponsor settings.', { sponsorId });
const presenter = new SponsorSettingsPresenter();
// Mock data // Mock data
const profile: SponsorProfileDTO = { const profile: SponsorProfileDTO = {
companyName: 'Acme Racing Co.', companyName: 'Acme Racing Co.',
@@ -359,7 +499,8 @@ export class SponsorService {
contactEmail: 'sponsor@acme-racing.com', contactEmail: 'sponsor@acme-racing.com',
contactPhone: '+1 (555) 123-4567', contactPhone: '+1 (555) 123-4567',
website: 'https://acme-racing.com', 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, logoUrl: null,
industry: 'Racing Equipment', industry: 'Racing Equipment',
address: { address: {
@@ -392,7 +533,8 @@ export class SponsorService {
allowDirectContact: true, allowDirectContact: true,
}; };
return { profile, notifications, privacy }; presenter.present({ profile, notifications, privacy });
return presenter;
} }
async updateSponsorSettings( async updateSponsorSettings(
@@ -401,12 +543,15 @@ export class SponsorService {
profile?: Partial<SponsorProfileDTO>; profile?: Partial<SponsorProfileDTO>;
notifications?: Partial<NotificationSettingsDTO>; notifications?: Partial<NotificationSettingsDTO>;
privacy?: Partial<PrivacySettingsDTO>; privacy?: Partial<PrivacySettingsDTO>;
} },
): Promise<void> { ): Promise<SponsorSettingsUpdatePresenter> {
this.logger.debug('[SponsorService] Updating sponsor settings.', { sponsorId, input }); this.logger.debug('[SponsorService] Updating sponsor settings.', { sponsorId, input });
// Mock implementation - in real app, this would persist to database // Mock implementation - in real app, this would persist to database
// For now, just log the update
this.logger.info('[SponsorService] Settings updated successfully.', { sponsorId }); 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'; import { GetEntitySponsorshipPricingResultDTO } from '../dtos/GetEntitySponsorshipPricingResultDTO';
export class GetEntitySponsorshipPricingPresenter { export class GetEntitySponsorshipPricingPresenter {
@@ -8,34 +8,34 @@ export class GetEntitySponsorshipPricingPresenter {
this.result = null; this.result = null;
} }
async present(output: GetEntitySponsorshipPricingOutputPort | null) { present(output: GetSponsorshipPricingOutputPort | null) {
if (!output) { if (!output) {
this.result = { pricing: [] }; this.result = {
entityType: 'season',
entityId: '',
pricing: [],
};
return; return;
} }
const pricing = []; this.result = {
if (output.mainSlot) { entityType: output.entityType,
pricing.push({ entityId: output.entityId,
id: `${output.entityType}-${output.entityId}-main`, pricing: output.pricing.map(item => ({
level: 'main', id: item.id,
price: output.mainSlot.price, level: item.level,
currency: output.mainSlot.currency, price: item.price,
}); currency: item.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 };
} }
getViewModel(): GetEntitySponsorshipPricingResultDTO | null { getViewModel(): GetEntitySponsorshipPricingResultDTO | null {
return this.result; 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'; import { GetPendingSponsorshipRequestsOutputDTO } from '../dtos/GetPendingSponsorshipRequestsOutputDTO';
export class GetPendingSponsorshipRequestsPresenter { export class GetPendingSponsorshipRequestsPresenter {
present(outputPort: PendingSponsorshipRequestsOutputPort): GetPendingSponsorshipRequestsOutputDTO { private result: GetPendingSponsorshipRequestsOutputDTO | null = null;
return {
reset() {
this.result = null;
}
present(outputPort: PendingSponsorshipRequestsOutputPort | null) {
if (!outputPort) {
this.result = null;
return;
}
this.result = {
entityType: outputPort.entityType, entityType: outputPort.entityType,
entityId: outputPort.entityId, entityId: outputPort.entityId,
requests: outputPort.requests, requests: outputPort.requests,
totalCount: outputPort.totalCount, 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'; import { SponsorDashboardDTO } from '../dtos/SponsorDashboardDTO';
export class GetSponsorDashboardPresenter { export class GetSponsorDashboardPresenter {
present(outputPort: SponsorDashboardOutputPort | null): SponsorDashboardDTO | null { private result: SponsorDashboardDTO | null = null;
return outputPort;
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'; import { SponsorSponsorshipsDTO } from '../dtos/SponsorSponsorshipsDTO';
export class GetSponsorSponsorshipsPresenter { export class GetSponsorSponsorshipsPresenter {
present(outputPort: SponsorSponsorshipsOutputPort | null): SponsorSponsorshipsDTO | null { private result: SponsorSponsorshipsDTO | null = null;
return outputPort;
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'; import { GetSponsorsOutputDTO } from '../dtos/GetSponsorsOutputDTO';
export class GetSponsorsPresenter { export class GetSponsorsPresenter {
present(outputPort: GetSponsorsOutputPort): GetSponsorsOutputDTO { private result: GetSponsorsOutputDTO | null = null;
return {
reset() {
this.result = null;
}
present(outputPort: GetSponsorsOutputPort) {
this.result = {
sponsors: outputPort.sponsors, 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