presenter refactoring
This commit is contained in:
@@ -18,6 +18,7 @@ describe('HelloService', () => {
|
||||
});
|
||||
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(service.getHello()).toBe('Hello World!');
|
||||
const presenter = service.getHello();
|
||||
expect(presenter.viewModel).toEqual({ message: 'Hello World!' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { HelloPresenter } from './presenters/HelloPresenter';
|
||||
|
||||
@Injectable()
|
||||
export class HelloService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
getHello(): HelloPresenter {
|
||||
const presenter = new HelloPresenter();
|
||||
presenter.present('Hello World!');
|
||||
return presenter;
|
||||
}
|
||||
}
|
||||
}
|
||||
22
apps/api/src/application/hello/presenters/HelloPresenter.ts
Normal file
22
apps/api/src/application/hello/presenters/HelloPresenter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -42,8 +42,8 @@ describe('AnalyticsController', () => {
|
||||
userAgent: 'Mozilla/5.0',
|
||||
country: 'US',
|
||||
};
|
||||
const output = { pageViewId: 'pv-123' };
|
||||
service.recordPageView.mockResolvedValue(output);
|
||||
const presenterMock = { viewModel: { pageViewId: 'pv-123' } };
|
||||
service.recordPageView.mockResolvedValue(presenterMock as any);
|
||||
|
||||
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
@@ -54,7 +54,7 @@ describe('AnalyticsController', () => {
|
||||
|
||||
expect(service.recordPageView).toHaveBeenCalledWith(input);
|
||||
expect(mockRes.status).toHaveBeenCalledWith(201);
|
||||
expect(mockRes.json).toHaveBeenCalledWith(output);
|
||||
expect(mockRes.json).toHaveBeenCalledWith(presenterMock.viewModel);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,8 +69,8 @@ describe('AnalyticsController', () => {
|
||||
actorId: 'actor-789',
|
||||
metadata: { key: 'value' },
|
||||
};
|
||||
const output = { eventId: 'event-123', engagementWeight: 10 };
|
||||
service.recordEngagement.mockResolvedValue(output);
|
||||
const presenterMock = { eventId: 'event-123', engagementWeight: 10 };
|
||||
service.recordEngagement.mockResolvedValue(presenterMock as any);
|
||||
|
||||
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
@@ -81,41 +81,45 @@ describe('AnalyticsController', () => {
|
||||
|
||||
expect(service.recordEngagement).toHaveBeenCalledWith(input);
|
||||
expect(mockRes.status).toHaveBeenCalledWith(201);
|
||||
expect(mockRes.json).toHaveBeenCalledWith(output);
|
||||
expect(mockRes.json).toHaveBeenCalledWith((presenterMock as any).viewModel);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDashboardData', () => {
|
||||
it('should return dashboard data', async () => {
|
||||
const output = {
|
||||
totalUsers: 100,
|
||||
activeUsers: 50,
|
||||
totalRaces: 20,
|
||||
totalLeagues: 5,
|
||||
const presenterMock = {
|
||||
viewModel: {
|
||||
totalUsers: 100,
|
||||
activeUsers: 50,
|
||||
totalRaces: 20,
|
||||
totalLeagues: 5,
|
||||
},
|
||||
};
|
||||
service.getDashboardData.mockResolvedValue(output);
|
||||
service.getDashboardData.mockResolvedValue(presenterMock as any);
|
||||
|
||||
const result = await controller.getDashboardData();
|
||||
|
||||
expect(service.getDashboardData).toHaveBeenCalled();
|
||||
expect(result).toEqual(output);
|
||||
expect(result).toEqual(presenterMock.viewModel);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAnalyticsMetrics', () => {
|
||||
it('should return analytics metrics', async () => {
|
||||
const output = {
|
||||
pageViews: 1000,
|
||||
uniqueVisitors: 500,
|
||||
averageSessionDuration: 300,
|
||||
bounceRate: 0.4,
|
||||
const presenterMock = {
|
||||
viewModel: {
|
||||
pageViews: 1000,
|
||||
uniqueVisitors: 500,
|
||||
averageSessionDuration: 300,
|
||||
bounceRate: 0.4,
|
||||
},
|
||||
};
|
||||
service.getAnalyticsMetrics.mockResolvedValue(output);
|
||||
service.getAnalyticsMetrics.mockResolvedValue(presenterMock as any);
|
||||
|
||||
const result = await controller.getAnalyticsMetrics();
|
||||
|
||||
expect(service.getAnalyticsMetrics).toHaveBeenCalled();
|
||||
expect(result).toEqual(output);
|
||||
expect(result).toEqual(presenterMock.viewModel);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,11 +10,7 @@ import { GetAnalyticsMetricsOutputDTO } from './dtos/GetAnalyticsMetricsOutputDT
|
||||
import { AnalyticsService } from './AnalyticsService';
|
||||
|
||||
type RecordPageViewInput = RecordPageViewInputDTO;
|
||||
type RecordPageViewOutput = RecordPageViewOutputDTO;
|
||||
type RecordEngagementInput = RecordEngagementInputDTO;
|
||||
type RecordEngagementOutput = RecordEngagementOutputDTO;
|
||||
type GetDashboardDataOutput = GetDashboardDataOutputDTO;
|
||||
type GetAnalyticsMetricsOutput = GetAnalyticsMetricsOutputDTO;
|
||||
|
||||
@ApiTags('analytics')
|
||||
@Controller('analytics')
|
||||
@@ -31,8 +27,8 @@ export class AnalyticsController {
|
||||
@Body() input: RecordPageViewInput,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const output: RecordPageViewOutput = await this.analyticsService.recordPageView(input);
|
||||
res.status(HttpStatus.CREATED).json(output);
|
||||
const presenter = await this.analyticsService.recordPageView(input);
|
||||
res.status(HttpStatus.CREATED).json(presenter.viewModel);
|
||||
}
|
||||
|
||||
@Post('engagement')
|
||||
@@ -43,21 +39,23 @@ export class AnalyticsController {
|
||||
@Body() input: RecordEngagementInput,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const output: RecordEngagementOutput = await this.analyticsService.recordEngagement(input);
|
||||
res.status(HttpStatus.CREATED).json(output);
|
||||
const presenter = await this.analyticsService.recordEngagement(input);
|
||||
res.status(HttpStatus.CREATED).json(presenter.viewModel);
|
||||
}
|
||||
|
||||
@Get('dashboard')
|
||||
@ApiOperation({ summary: 'Get analytics dashboard data' })
|
||||
@ApiResponse({ status: 200, description: 'Dashboard data', type: GetDashboardDataOutputDTO })
|
||||
async getDashboardData(): Promise<GetDashboardDataOutput> {
|
||||
return await this.analyticsService.getDashboardData();
|
||||
async getDashboardData(): Promise<GetDashboardDataOutputDTO> {
|
||||
const presenter = await this.analyticsService.getDashboardData();
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Get('metrics')
|
||||
@ApiOperation({ summary: 'Get analytics metrics' })
|
||||
@ApiResponse({ status: 200, description: 'Analytics metrics', type: GetAnalyticsMetricsOutputDTO })
|
||||
async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsOutput> {
|
||||
return await this.analyticsService.getAnalyticsMetrics();
|
||||
async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsOutputDTO> {
|
||||
const presenter = await this.analyticsService.getAnalyticsMetrics();
|
||||
return presenter.viewModel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,13 @@ import { RecordPageViewUseCase } from '@core/analytics/application/use-cases/Rec
|
||||
import { RecordEngagementUseCase } from '@core/analytics/application/use-cases/RecordEngagementUseCase';
|
||||
import { GetDashboardDataUseCase } from '@core/analytics/application/use-cases/GetDashboardDataUseCase';
|
||||
import { GetAnalyticsMetricsUseCase } from '@core/analytics/application/use-cases/GetAnalyticsMetricsUseCase';
|
||||
import { RecordPageViewPresenter } from './presenters/RecordPageViewPresenter';
|
||||
import { RecordEngagementPresenter } from './presenters/RecordEngagementPresenter';
|
||||
import { GetDashboardDataPresenter } from './presenters/GetDashboardDataPresenter';
|
||||
import { GetAnalyticsMetricsPresenter } from './presenters/GetAnalyticsMetricsPresenter';
|
||||
|
||||
type RecordPageViewInput = RecordPageViewInputDTO;
|
||||
type RecordPageViewOutput = RecordPageViewOutputDTO;
|
||||
type RecordEngagementInput = RecordEngagementInputDTO;
|
||||
type RecordEngagementOutput = RecordEngagementOutputDTO;
|
||||
type GetDashboardDataOutput = GetDashboardDataOutputDTO;
|
||||
type GetAnalyticsMetricsOutput = GetAnalyticsMetricsOutputDTO;
|
||||
|
||||
const RECORD_PAGE_VIEW_USE_CASE_TOKEN = 'RecordPageViewUseCase_TOKEN';
|
||||
const RECORD_ENGAGEMENT_USE_CASE_TOKEN = 'RecordEngagementUseCase_TOKEN';
|
||||
@@ -31,19 +31,27 @@ export class AnalyticsService {
|
||||
@Inject(GET_ANALYTICS_METRICS_USE_CASE_TOKEN) private readonly getAnalyticsMetricsUseCase: GetAnalyticsMetricsUseCase,
|
||||
) {}
|
||||
|
||||
async recordPageView(input: RecordPageViewInput): Promise<RecordPageViewOutput> {
|
||||
return await this.recordPageViewUseCase.execute(input);
|
||||
async recordPageView(input: RecordPageViewInput): Promise<RecordPageViewPresenter> {
|
||||
const presenter = new RecordPageViewPresenter();
|
||||
await this.recordPageViewUseCase.execute(input, presenter);
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async recordEngagement(input: RecordEngagementInput): Promise<RecordEngagementOutput> {
|
||||
return await this.recordEngagementUseCase.execute(input);
|
||||
async recordEngagement(input: RecordEngagementInput): Promise<RecordEngagementPresenter> {
|
||||
const presenter = new RecordEngagementPresenter();
|
||||
await this.recordEngagementUseCase.execute(input, presenter);
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getDashboardData(): Promise<GetDashboardDataOutput> {
|
||||
return await this.getDashboardDataUseCase.execute();
|
||||
async getDashboardData(): Promise<GetDashboardDataPresenter> {
|
||||
const presenter = new GetDashboardDataPresenter();
|
||||
await this.getDashboardDataUseCase.execute(undefined, presenter);
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsOutput> {
|
||||
return await this.getAnalyticsMetricsUseCase.execute();
|
||||
async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsPresenter> {
|
||||
const presenter = new GetAnalyticsMetricsPresenter();
|
||||
await this.getAnalyticsMetricsUseCase.execute(undefined, presenter);
|
||||
return presenter;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -8,22 +8,26 @@ export class AuthController {
|
||||
|
||||
@Post('signup')
|
||||
async signup(@Body() params: SignupParams): Promise<AuthSessionDTO> {
|
||||
return this.authService.signupWithEmail(params);
|
||||
const presenter = await this.authService.signupWithEmail(params);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
async login(@Body() params: LoginParams): Promise<AuthSessionDTO> {
|
||||
return this.authService.loginWithEmail(params);
|
||||
const presenter = await this.authService.loginWithEmail(params);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Get('session')
|
||||
async getSession(): Promise<AuthSessionDTO | null> {
|
||||
return this.authService.getCurrentSession();
|
||||
const presenter = await this.authService.getCurrentSession();
|
||||
return presenter ? presenter.viewModel : null;
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
async logout(): Promise<void> {
|
||||
return this.authService.logout();
|
||||
async logout(): Promise<{ success: boolean }> {
|
||||
const presenter = await this.authService.logout();
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import type { IPasswordHashingService } from '@core/identity/domain/services/Pas
|
||||
import type { Logger } from "@core/shared/application";
|
||||
import { AUTH_REPOSITORY_TOKEN, IDENTITY_SESSION_PORT_TOKEN, LOGGER_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, USER_REPOSITORY_TOKEN } from './AuthProviders';
|
||||
import { AuthSessionDTO, LoginParams, SignupParams, AuthenticatedUserDTO } from './dtos/AuthDto';
|
||||
import { AuthSessionPresenter } from './presenters/AuthSessionPresenter';
|
||||
import { CommandResultPresenter } from './presenters/CommandResultPresenter';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -50,7 +52,7 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
async getCurrentSession(): Promise<AuthSessionDTO | null> {
|
||||
async getCurrentSession(): Promise<AuthSessionPresenter | null> {
|
||||
this.logger.debug('[AuthService] Attempting to get current session.');
|
||||
const coreSession = await this.identitySessionPort.getCurrentSession();
|
||||
if (!coreSession) {
|
||||
@@ -59,36 +61,34 @@ export class AuthService {
|
||||
|
||||
const user = await this.userRepository.findById(coreSession.user.id); // Use userRepository to fetch full user
|
||||
if (!user) {
|
||||
// If session exists but user doesn't in DB, perhaps clear session?
|
||||
this.logger.warn(`[AuthService] Session found for user ID ${coreSession.user.id}, but user not found in repository.`);
|
||||
await this.identitySessionPort.clearSession(); // Clear potentially stale session
|
||||
return null;
|
||||
// If session exists but user doesn't in DB, perhaps clear session?
|
||||
this.logger.warn(`[AuthService] Session found for user ID ${coreSession.user.id}, but user not found in repository.`);
|
||||
await this.identitySessionPort.clearSession(); // Clear potentially stale session
|
||||
return null;
|
||||
}
|
||||
|
||||
const authenticatedUserDTO = this.mapUserToAuthenticatedUserDTO(User.fromStored(user));
|
||||
|
||||
return {
|
||||
token: coreSession.token,
|
||||
user: authenticatedUserDTO,
|
||||
};
|
||||
const presenter = new AuthSessionPresenter();
|
||||
presenter.present({ token: coreSession.token, user: authenticatedUserDTO });
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async signupWithEmail(params: SignupParams): Promise<AuthSessionDTO> {
|
||||
async signupWithEmail(params: SignupParams): Promise<AuthSessionPresenter> {
|
||||
this.logger.debug(`[AuthService] Attempting signup for email: ${params.email}`);
|
||||
const user = await this.signupUseCase.execute(params.email, params.password, params.displayName);
|
||||
|
||||
|
||||
// Create session after successful signup
|
||||
const authenticatedUserDTO = this.mapUserToAuthenticatedUserDTO(user);
|
||||
const coreDto = this.mapToCoreAuthenticatedUserDTO(authenticatedUserDTO);
|
||||
const session = await this.identitySessionPort.createSession(coreDto);
|
||||
|
||||
return {
|
||||
token: session.token,
|
||||
user: authenticatedUserDTO,
|
||||
};
|
||||
const presenter = new AuthSessionPresenter();
|
||||
presenter.present({ token: session.token, user: authenticatedUserDTO });
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async loginWithEmail(params: LoginParams): Promise<AuthSessionDTO> {
|
||||
async loginWithEmail(params: LoginParams): Promise<AuthSessionPresenter> {
|
||||
this.logger.debug(`[AuthService] Attempting login for email: ${params.email}`);
|
||||
try {
|
||||
const user = await this.loginUseCase.execute(params.email, params.password);
|
||||
@@ -97,10 +97,9 @@ export class AuthService {
|
||||
const coreDto = this.mapToCoreAuthenticatedUserDTO(authenticatedUserDTO);
|
||||
const session = await this.identitySessionPort.createSession(coreDto);
|
||||
|
||||
return {
|
||||
token: session.token,
|
||||
user: authenticatedUserDTO,
|
||||
};
|
||||
const presenter = new AuthSessionPresenter();
|
||||
presenter.present({ token: session.token, user: authenticatedUserDTO });
|
||||
return presenter;
|
||||
} catch (error) {
|
||||
this.logger.error(`[AuthService] Login failed for email ${params.email}:`, error instanceof Error ? error : new Error(String(error)));
|
||||
throw new InternalServerErrorException('Login failed due to invalid credentials or server error.');
|
||||
@@ -108,8 +107,11 @@ export class AuthService {
|
||||
}
|
||||
|
||||
|
||||
async logout(): Promise<void> {
|
||||
async logout(): Promise<CommandResultPresenter> {
|
||||
this.logger.debug('[AuthService] Attempting logout.');
|
||||
const presenter = new CommandResultPresenter();
|
||||
await this.logoutUseCase.execute();
|
||||
presenter.present({ success: true });
|
||||
return presenter;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
31
apps/api/src/domain/auth/presenters/AuthSessionPresenter.ts
Normal file
31
apps/api/src/domain/auth/presenters/AuthSessionPresenter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export class DashboardController {
|
||||
@ApiQuery({ name: 'driverId', description: 'Driver ID' })
|
||||
@ApiResponse({ status: 200, description: 'Dashboard overview', type: DashboardOverviewDTO })
|
||||
async getDashboardOverview(@Query('driverId') driverId: string): Promise<DashboardOverviewDTO> {
|
||||
return this.dashboardService.getDashboardOverview(driverId);
|
||||
const presenter = await this.dashboardService.getDashboardOverview(driverId);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { plainToClass } from 'class-transformer';
|
||||
import { DashboardOverviewUseCase } from '@core/racing/application/use-cases/DashboardOverviewUseCase';
|
||||
import type { DashboardOverviewOutputPort } from '@core/racing/application/ports/output/DashboardOverviewOutputPort';
|
||||
import { DashboardOverviewDTO } from './dtos/DashboardOverviewDTO';
|
||||
import { DashboardOverviewPresenter } from './presenters/DashboardOverviewPresenter';
|
||||
|
||||
// Core imports
|
||||
import type { Logger } from '@core/shared/application/Logger';
|
||||
@@ -64,7 +64,7 @@ export class DashboardService {
|
||||
);
|
||||
}
|
||||
|
||||
async getDashboardOverview(driverId: string): Promise<DashboardOverviewDTO> {
|
||||
async getDashboardOverview(driverId: string): Promise<DashboardOverviewPresenter> {
|
||||
this.logger.debug('[DashboardService] Getting dashboard overview:', { driverId });
|
||||
|
||||
const result = await this.dashboardOverviewUseCase.execute({ driverId });
|
||||
@@ -73,6 +73,8 @@ export class DashboardService {
|
||||
throw new Error(result.error?.message || 'Failed to get dashboard overview');
|
||||
}
|
||||
|
||||
return plainToClass(DashboardOverviewDTO, result.value);
|
||||
const presenter = new DashboardOverviewPresenter();
|
||||
presenter.present(result.value as DashboardOverviewOutputPort);
|
||||
return presenter;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ describe('DriverController', () => {
|
||||
describe('getDriversLeaderboard', () => {
|
||||
it('should return drivers leaderboard', async () => {
|
||||
const leaderboard: DriversLeaderboardDTO = { items: [] };
|
||||
service.getDriversLeaderboard.mockResolvedValue(leaderboard);
|
||||
service.getDriversLeaderboard.mockResolvedValue({ viewModel: leaderboard } as never);
|
||||
|
||||
const result = await controller.getDriversLeaderboard();
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('DriverController', () => {
|
||||
describe('getTotalDrivers', () => {
|
||||
it('should return total drivers stats', async () => {
|
||||
const stats: DriverStatsDTO = { totalDrivers: 100 };
|
||||
service.getTotalDrivers.mockResolvedValue(stats);
|
||||
service.getTotalDrivers.mockResolvedValue({ viewModel: stats } as never);
|
||||
|
||||
const result = await controller.getTotalDrivers();
|
||||
|
||||
@@ -70,8 +70,8 @@ describe('DriverController', () => {
|
||||
describe('getCurrentDriver', () => {
|
||||
it('should return current driver if userId exists', async () => {
|
||||
const userId = 'user-123';
|
||||
const driver: GetDriverOutputDTO = { id: 'driver-123', name: 'Driver' };
|
||||
service.getCurrentDriver.mockResolvedValue(driver);
|
||||
const driver: GetDriverOutputDTO = { id: 'driver-123', name: 'Driver' } as GetDriverOutputDTO;
|
||||
service.getCurrentDriver.mockResolvedValue({ viewModel: driver } as never);
|
||||
|
||||
const mockReq: Partial<AuthenticatedRequest> = { user: { userId } };
|
||||
|
||||
@@ -94,9 +94,9 @@ describe('DriverController', () => {
|
||||
describe('completeOnboarding', () => {
|
||||
it('should complete onboarding', async () => {
|
||||
const userId = 'user-123';
|
||||
const input: CompleteOnboardingInputDTO = { someField: 'value' };
|
||||
const input: CompleteOnboardingInputDTO = { someField: 'value' } as CompleteOnboardingInputDTO;
|
||||
const output: CompleteOnboardingOutputDTO = { success: true };
|
||||
service.completeOnboarding.mockResolvedValue(output);
|
||||
service.completeOnboarding.mockResolvedValue({ viewModel: output } as never);
|
||||
|
||||
const mockReq: Partial<AuthenticatedRequest> = { user: { userId } };
|
||||
|
||||
@@ -111,8 +111,8 @@ describe('DriverController', () => {
|
||||
it('should return registration status', async () => {
|
||||
const driverId = 'driver-123';
|
||||
const raceId = 'race-456';
|
||||
const status: DriverRegistrationStatusDTO = { registered: true };
|
||||
service.getDriverRegistrationStatus.mockResolvedValue(status);
|
||||
const status: DriverRegistrationStatusDTO = { registered: true } as DriverRegistrationStatusDTO;
|
||||
service.getDriverRegistrationStatus.mockResolvedValue({ viewModel: status } as never);
|
||||
|
||||
const result = await controller.getDriverRegistrationStatus(driverId, raceId);
|
||||
|
||||
@@ -124,8 +124,8 @@ describe('DriverController', () => {
|
||||
describe('getDriver', () => {
|
||||
it('should return driver by id', async () => {
|
||||
const driverId = 'driver-123';
|
||||
const driver: GetDriverOutputDTO = { id: driverId, name: 'Driver' };
|
||||
service.getDriver.mockResolvedValue(driver);
|
||||
const driver: GetDriverOutputDTO = { id: driverId, name: 'Driver' } as GetDriverOutputDTO;
|
||||
service.getDriver.mockResolvedValue({ viewModel: driver } as never);
|
||||
|
||||
const result = await controller.getDriver(driverId);
|
||||
|
||||
|
||||
@@ -25,14 +25,16 @@ export class DriverController {
|
||||
@ApiOperation({ summary: 'Get drivers leaderboard' })
|
||||
@ApiResponse({ status: 200, description: 'List of drivers for the leaderboard', type: DriversLeaderboardDTO })
|
||||
async getDriversLeaderboard(): Promise<DriversLeaderboardDTO> {
|
||||
return this.driverService.getDriversLeaderboard();
|
||||
const presenter = await this.driverService.getDriversLeaderboard();
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Get('total-drivers')
|
||||
@ApiOperation({ summary: 'Get the total number of drivers' })
|
||||
@ApiResponse({ status: 200, description: 'Total number of drivers', type: DriverStatsDTO })
|
||||
async getTotalDrivers(): Promise<DriverStatsDTO> {
|
||||
return this.driverService.getTotalDrivers();
|
||||
const presenter = await this.driverService.getTotalDrivers();
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Get('current')
|
||||
@@ -40,12 +42,13 @@ export class DriverController {
|
||||
@ApiResponse({ status: 200, description: 'Current driver data', type: GetDriverOutputDTO })
|
||||
@ApiResponse({ status: 404, description: 'Driver not found' })
|
||||
async getCurrentDriver(@Req() req: AuthenticatedRequest): Promise<GetDriverOutputDTO | null> {
|
||||
// Assuming userId is available from the request (e.g., via auth middleware)
|
||||
const userId = req.user?.userId;
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
return this.driverService.getCurrentDriver(userId);
|
||||
|
||||
const presenter = await this.driverService.getCurrentDriver(userId);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Post('complete-onboarding')
|
||||
@@ -55,9 +58,9 @@ export class DriverController {
|
||||
@Body() input: CompleteOnboardingInputDTO,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<CompleteOnboardingOutputDTO> {
|
||||
// Assuming userId is available from the request (e.g., via auth middleware)
|
||||
const userId = req.user!.userId; // Placeholder for actual user extraction
|
||||
return this.driverService.completeOnboarding(userId, input);
|
||||
const userId = req.user!.userId;
|
||||
const presenter = await this.driverService.completeOnboarding(userId, input);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Get(':driverId/races/:raceId/registration-status')
|
||||
@@ -67,7 +70,8 @@ export class DriverController {
|
||||
@Param('driverId') driverId: string,
|
||||
@Param('raceId') raceId: string,
|
||||
): Promise<DriverRegistrationStatusDTO> {
|
||||
return this.driverService.getDriverRegistrationStatus({ driverId, raceId });
|
||||
const presenter = await this.driverService.getDriverRegistrationStatus({ driverId, raceId });
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Get(':driverId')
|
||||
@@ -75,7 +79,8 @@ export class DriverController {
|
||||
@ApiResponse({ status: 200, description: 'Driver data', type: GetDriverOutputDTO })
|
||||
@ApiResponse({ status: 404, description: 'Driver not found' })
|
||||
async getDriver(@Param('driverId') driverId: string): Promise<GetDriverOutputDTO | null> {
|
||||
return this.driverService.getDriver(driverId);
|
||||
const presenter = await this.driverService.getDriver(driverId);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Get(':driverId/profile')
|
||||
@@ -83,7 +88,8 @@ export class DriverController {
|
||||
@ApiResponse({ status: 200, description: 'Driver profile data', type: GetDriverProfileOutputDTO })
|
||||
@ApiResponse({ status: 404, description: 'Driver not found' })
|
||||
async getDriverProfile(@Param('driverId') driverId: string): Promise<GetDriverProfileOutputDTO> {
|
||||
return this.driverService.getDriverProfile(driverId);
|
||||
const presenter = await this.driverService.getDriverProfile(driverId);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Put(':driverId/profile')
|
||||
@@ -93,7 +99,8 @@ export class DriverController {
|
||||
@Param('driverId') driverId: string,
|
||||
@Body() body: { bio?: string; country?: string },
|
||||
): Promise<GetDriverOutputDTO | null> {
|
||||
return this.driverService.updateDriverProfile(driverId, body.bio, body.country);
|
||||
const presenter = await this.driverService.updateDriverProfile(driverId, body.bio, body.country);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
// Add other Driver endpoints here based on other presenters
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { DriversLeaderboardDTO } from './dtos/DriversLeaderboardDTO';
|
||||
import { DriverStatsDTO } from './dtos/DriverStatsDTO';
|
||||
import { CompleteOnboardingInputDTO } from './dtos/CompleteOnboardingInputDTO';
|
||||
import { CompleteOnboardingOutputDTO } from './dtos/CompleteOnboardingOutputDTO';
|
||||
import { GetDriverRegistrationStatusQueryDTO } from './dtos/GetDriverRegistrationStatusQueryDTO';
|
||||
import { DriverRegistrationStatusDTO } from './dtos/DriverRegistrationStatusDTO';
|
||||
import { GetDriverOutputDTO } from './dtos/GetDriverOutputDTO';
|
||||
import { GetDriverProfileOutputDTO } from './dtos/GetDriverProfileOutputDTO';
|
||||
|
||||
// Use cases
|
||||
import { GetDriversLeaderboardUseCase } from '@core/racing/application/use-cases/GetDriversLeaderboardUseCase';
|
||||
@@ -14,42 +8,66 @@ import { GetTotalDriversUseCase } from '@core/racing/application/use-cases/GetTo
|
||||
import { CompleteDriverOnboardingUseCase } from '@core/racing/application/use-cases/CompleteDriverOnboardingUseCase';
|
||||
import { IsDriverRegisteredForRaceUseCase } from '@core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase';
|
||||
import { GetProfileOverviewUseCase } from '@core/racing/application/use-cases/GetProfileOverviewUseCase';
|
||||
import { UpdateDriverProfileUseCase } from '@core/racing/application/use-cases/UpdateDriverProfileUseCase';
|
||||
|
||||
// Presenters
|
||||
import { DriverStatsPresenter } from './presenters/DriverStatsPresenter';
|
||||
import { DriversLeaderboardPresenter } from './presenters/DriversLeaderboardPresenter';
|
||||
import { CompleteOnboardingPresenter } from './presenters/CompleteOnboardingPresenter';
|
||||
import { DriverRegistrationStatusPresenter } from './presenters/DriverRegistrationStatusPresenter';
|
||||
import { DriverPresenter } from './presenters/DriverPresenter';
|
||||
import { DriverProfilePresenter } from './presenters/DriverProfilePresenter';
|
||||
|
||||
// Tokens
|
||||
import { GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN, GET_TOTAL_DRIVERS_USE_CASE_TOKEN, COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN, IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN, UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN, GET_PROFILE_OVERVIEW_USE_CASE_TOKEN, LOGGER_TOKEN, DRIVER_REPOSITORY_TOKEN } from './DriverProviders';
|
||||
import type { ProfileOverviewOutputPort } from '@core/racing/application/ports/output/ProfileOverviewOutputPort';
|
||||
import {
|
||||
GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN,
|
||||
GET_TOTAL_DRIVERS_USE_CASE_TOKEN,
|
||||
COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN,
|
||||
IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN,
|
||||
UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN,
|
||||
GET_PROFILE_OVERVIEW_USE_CASE_TOKEN,
|
||||
LOGGER_TOKEN,
|
||||
DRIVER_REPOSITORY_TOKEN,
|
||||
} from './DriverProviders';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
|
||||
import { UpdateDriverProfileUseCase } from '@core/racing/application/use-cases/UpdateDriverProfileUseCase';
|
||||
|
||||
@Injectable()
|
||||
export class DriverService {
|
||||
constructor(
|
||||
@Inject(GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN) private readonly getDriversLeaderboardUseCase: GetDriversLeaderboardUseCase,
|
||||
@Inject(GET_TOTAL_DRIVERS_USE_CASE_TOKEN) private readonly getTotalDriversUseCase: GetTotalDriversUseCase,
|
||||
@Inject(COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN) private readonly completeDriverOnboardingUseCase: CompleteDriverOnboardingUseCase,
|
||||
@Inject(IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN) private readonly isDriverRegisteredForRaceUseCase: IsDriverRegisteredForRaceUseCase,
|
||||
@Inject(UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN) private readonly updateDriverProfileUseCase: UpdateDriverProfileUseCase,
|
||||
@Inject(GET_PROFILE_OVERVIEW_USE_CASE_TOKEN) private readonly getProfileOverviewUseCase: GetProfileOverviewUseCase,
|
||||
@Inject(DRIVER_REPOSITORY_TOKEN) private readonly driverRepository: IDriverRepository,
|
||||
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
|
||||
@Inject(GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN)
|
||||
private readonly getDriversLeaderboardUseCase: GetDriversLeaderboardUseCase,
|
||||
@Inject(GET_TOTAL_DRIVERS_USE_CASE_TOKEN)
|
||||
private readonly getTotalDriversUseCase: GetTotalDriversUseCase,
|
||||
@Inject(COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN)
|
||||
private readonly completeDriverOnboardingUseCase: CompleteDriverOnboardingUseCase,
|
||||
@Inject(IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN)
|
||||
private readonly isDriverRegisteredForRaceUseCase: IsDriverRegisteredForRaceUseCase,
|
||||
@Inject(UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN)
|
||||
private readonly updateDriverProfileUseCase: UpdateDriverProfileUseCase,
|
||||
@Inject(GET_PROFILE_OVERVIEW_USE_CASE_TOKEN)
|
||||
private readonly getProfileOverviewUseCase: GetProfileOverviewUseCase,
|
||||
@Inject(DRIVER_REPOSITORY_TOKEN)
|
||||
private readonly driverRepository: IDriverRepository,
|
||||
@Inject(LOGGER_TOKEN)
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async getDriversLeaderboard(): Promise<DriversLeaderboardDTO> {
|
||||
async getDriversLeaderboard(): Promise<DriversLeaderboardPresenter> {
|
||||
this.logger.debug('[DriverService] Fetching drivers leaderboard.');
|
||||
|
||||
const result = await this.getDriversLeaderboardUseCase.execute();
|
||||
if (result.isOk()) {
|
||||
return result.value as DriversLeaderboardDTO;
|
||||
} else {
|
||||
throw new Error(`Failed to fetch drivers leaderboard: ${result.error.details.message}`);
|
||||
if (result.isErr()) {
|
||||
throw new Error(`Failed to fetch drivers leaderboard: ${result.unwrapErr().details.message}`);
|
||||
}
|
||||
|
||||
const presenter = new DriversLeaderboardPresenter();
|
||||
presenter.reset();
|
||||
presenter.present(result.unwrap());
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getTotalDrivers(): Promise<DriverStatsDTO> {
|
||||
async getTotalDrivers(): Promise<DriverStatsPresenter> {
|
||||
this.logger.debug('[DriverService] Fetching total drivers count.');
|
||||
|
||||
const result = await this.getTotalDriversUseCase.execute();
|
||||
@@ -58,11 +76,12 @@ export class DriverService {
|
||||
}
|
||||
|
||||
const presenter = new DriverStatsPresenter();
|
||||
presenter.reset();
|
||||
presenter.present(result.unwrap());
|
||||
return presenter.viewModel;
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async completeOnboarding(userId: string, input: CompleteOnboardingInputDTO): Promise<CompleteOnboardingOutputDTO> {
|
||||
async completeOnboarding(userId: string, input: CompleteOnboardingInputDTO): Promise<CompleteOnboardingPresenter> {
|
||||
this.logger.debug('Completing onboarding for user:', userId);
|
||||
|
||||
const result = await this.completeDriverOnboardingUseCase.execute({
|
||||
@@ -75,80 +94,88 @@ export class DriverService {
|
||||
bio: input.bio,
|
||||
});
|
||||
|
||||
const presenter = new CompleteOnboardingPresenter();
|
||||
presenter.reset();
|
||||
|
||||
if (result.isOk()) {
|
||||
return {
|
||||
success: true,
|
||||
driverId: result.value.driverId,
|
||||
};
|
||||
presenter.present(result.value);
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
errorMessage: result.error.code,
|
||||
};
|
||||
presenter.presentError(result.error.code);
|
||||
}
|
||||
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getDriverRegistrationStatus(query: GetDriverRegistrationStatusQueryDTO): Promise<DriverRegistrationStatusDTO> {
|
||||
async getDriverRegistrationStatus(
|
||||
query: GetDriverRegistrationStatusQueryDTO,
|
||||
): Promise<DriverRegistrationStatusPresenter> {
|
||||
this.logger.debug('Checking driver registration status:', query);
|
||||
|
||||
const result = await this.isDriverRegisteredForRaceUseCase.execute({ raceId: query.raceId, driverId: query.driverId });
|
||||
if (result.isOk()) {
|
||||
return result.value;
|
||||
} else {
|
||||
// For now, throw error or handle appropriately. Since it's a query, perhaps return default or throw.
|
||||
throw new Error(`Failed to check registration status: ${result.error.code}`);
|
||||
const result = await this.isDriverRegisteredForRaceUseCase.execute({
|
||||
raceId: query.raceId,
|
||||
driverId: query.driverId,
|
||||
});
|
||||
|
||||
if (result.isErr()) {
|
||||
throw new Error(`Failed to check registration status: ${result.unwrapErr().code}`);
|
||||
}
|
||||
|
||||
const presenter = new DriverRegistrationStatusPresenter();
|
||||
presenter.reset();
|
||||
|
||||
const output = result.unwrap();
|
||||
presenter.present(output.isRegistered, output.raceId, output.driverId);
|
||||
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getCurrentDriver(userId: string): Promise<GetDriverOutputDTO | null> {
|
||||
async getCurrentDriver(userId: string): Promise<DriverPresenter> {
|
||||
this.logger.debug(`[DriverService] Fetching current driver for userId: ${userId}`);
|
||||
|
||||
const driver = await this.driverRepository.findById(userId);
|
||||
if (!driver) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: driver.id,
|
||||
iracingId: driver.iracingId.value,
|
||||
name: driver.name.value,
|
||||
country: driver.country.value,
|
||||
bio: driver.bio?.value,
|
||||
joinedAt: driver.joinedAt.toISOString(),
|
||||
};
|
||||
const presenter = new DriverPresenter();
|
||||
presenter.reset();
|
||||
presenter.present(driver ?? null);
|
||||
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async updateDriverProfile(driverId: string, bio?: string, country?: string): Promise<GetDriverOutputDTO | null> {
|
||||
async updateDriverProfile(
|
||||
driverId: string,
|
||||
bio?: string,
|
||||
country?: string,
|
||||
): Promise<DriverPresenter> {
|
||||
this.logger.debug(`[DriverService] Updating driver profile for driverId: ${driverId}`);
|
||||
|
||||
const result = await this.updateDriverProfileUseCase.execute({ driverId, bio, country });
|
||||
|
||||
const presenter = new DriverPresenter();
|
||||
presenter.reset();
|
||||
|
||||
if (result.isErr()) {
|
||||
this.logger.error(`Failed to update driver profile: ${result.error.code}`);
|
||||
return null;
|
||||
presenter.present(null);
|
||||
return presenter;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
presenter.present(result.value);
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getDriver(driverId: string): Promise<GetDriverOutputDTO | null> {
|
||||
async getDriver(driverId: string): Promise<DriverPresenter> {
|
||||
this.logger.debug(`[DriverService] Fetching driver for driverId: ${driverId}`);
|
||||
|
||||
const driver = await this.driverRepository.findById(driverId);
|
||||
if (!driver) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: driver.id,
|
||||
iracingId: driver.iracingId.value,
|
||||
name: driver.name.value,
|
||||
country: driver.country.value,
|
||||
bio: driver.bio?.value,
|
||||
joinedAt: driver.joinedAt.toISOString(),
|
||||
};
|
||||
const presenter = new DriverPresenter();
|
||||
presenter.reset();
|
||||
presenter.present(driver ?? null);
|
||||
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getDriverProfile(driverId: string): Promise<GetDriverProfileOutputDTO> {
|
||||
async getDriverProfile(driverId: string): Promise<DriverProfilePresenter> {
|
||||
this.logger.debug(`[DriverService] Fetching driver profile for driverId: ${driverId}`);
|
||||
|
||||
const result = await this.getProfileOverviewUseCase.execute({ driverId });
|
||||
@@ -156,37 +183,10 @@ export class DriverService {
|
||||
throw new Error(`Failed to fetch driver profile: ${result.error.code}`);
|
||||
}
|
||||
|
||||
const outputPort = result.value;
|
||||
return this.mapProfileOverviewToDTO(outputPort);
|
||||
}
|
||||
const presenter = new DriverProfilePresenter();
|
||||
presenter.reset();
|
||||
presenter.present(result.value);
|
||||
|
||||
private mapProfileOverviewToDTO(outputPort: ProfileOverviewOutputPort): GetDriverProfileOutputDTO {
|
||||
return {
|
||||
currentDriver: outputPort.driver ? {
|
||||
id: outputPort.driver.id,
|
||||
name: outputPort.driver.name,
|
||||
country: outputPort.driver.country,
|
||||
avatarUrl: outputPort.driver.avatarUrl,
|
||||
iracingId: outputPort.driver.iracingId,
|
||||
joinedAt: outputPort.driver.joinedAt.toISOString(),
|
||||
rating: outputPort.driver.rating,
|
||||
globalRank: outputPort.driver.globalRank,
|
||||
consistency: outputPort.driver.consistency,
|
||||
bio: outputPort.driver.bio,
|
||||
totalDrivers: outputPort.driver.totalDrivers,
|
||||
} : null,
|
||||
stats: outputPort.stats,
|
||||
finishDistribution: outputPort.finishDistribution,
|
||||
teamMemberships: outputPort.teamMemberships.map(membership => ({
|
||||
teamId: membership.teamId,
|
||||
teamName: membership.teamName,
|
||||
teamTag: membership.teamTag,
|
||||
role: membership.role,
|
||||
joinedAt: membership.joinedAt.toISOString(),
|
||||
isCurrent: membership.isCurrent,
|
||||
})),
|
||||
socialSummary: outputPort.socialSummary,
|
||||
extendedProfile: outputPort.extendedProfile,
|
||||
};
|
||||
return presenter;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
30
apps/api/src/domain/driver/presenters/DriverPresenter.ts
Normal file
30
apps/api/src/domain/driver/presenters/DriverPresenter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,51 +1,31 @@
|
||||
import { DriversLeaderboardDTO } from '../dtos/DriversLeaderboardDTO';
|
||||
import { DriverLeaderboardItemDTO } from '../dtos/DriverLeaderboardItemDTO';
|
||||
import type { IDriversLeaderboardPresenter, DriversLeaderboardResultDTO } from '../../../../../core/racing/application/presenters/IDriversLeaderboardPresenter';
|
||||
import { SkillLevelService } from '../../../../../core/racing/domain/services/SkillLevelService';
|
||||
import type { DriversLeaderboardOutputPort } from '../../../../../core/racing/application/ports/output/DriversLeaderboardOutputPort';
|
||||
|
||||
export class DriversLeaderboardPresenter implements IDriversLeaderboardPresenter {
|
||||
export class DriversLeaderboardPresenter {
|
||||
private result: DriversLeaderboardDTO | null = null;
|
||||
|
||||
reset() {
|
||||
reset(): void {
|
||||
this.result = null;
|
||||
}
|
||||
|
||||
present(dto: DriversLeaderboardResultDTO) {
|
||||
const drivers: DriverLeaderboardItemDTO[] = dto.drivers.map(driver => {
|
||||
const ranking = dto.rankings.find(r => r.driverId === driver.id);
|
||||
const stats = dto.stats[driver.id];
|
||||
const avatarUrl = dto.avatarUrls[driver.id];
|
||||
|
||||
const rating = ranking?.rating ?? 0;
|
||||
const racesCompleted = stats?.racesCompleted ?? 0;
|
||||
|
||||
return {
|
||||
present(output: DriversLeaderboardOutputPort): void {
|
||||
this.result = {
|
||||
drivers: output.drivers.map(driver => ({
|
||||
id: driver.id,
|
||||
name: driver.name,
|
||||
rating,
|
||||
// Use core SkillLevelService to derive band from rating
|
||||
skillLevel: SkillLevelService.getSkillLevel(rating),
|
||||
nationality: driver.country,
|
||||
racesCompleted,
|
||||
wins: stats?.wins ?? 0,
|
||||
podiums: stats?.podiums ?? 0,
|
||||
// Consider a driver active if they have completed at least one race
|
||||
isActive: racesCompleted > 0,
|
||||
rank: ranking?.overallRank ?? 0,
|
||||
avatarUrl,
|
||||
};
|
||||
});
|
||||
|
||||
// Calculate totals
|
||||
const totalRaces = drivers.reduce((sum, d) => sum + (d.racesCompleted ?? 0), 0);
|
||||
const totalWins = drivers.reduce((sum, d) => sum + (d.wins ?? 0), 0);
|
||||
const activeCount = drivers.filter(d => d.isActive).length;
|
||||
|
||||
this.result = {
|
||||
drivers: drivers.sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0)),
|
||||
totalRaces,
|
||||
totalWins,
|
||||
activeCount,
|
||||
rating: driver.rating,
|
||||
skillLevel: driver.skillLevel,
|
||||
nationality: driver.nationality,
|
||||
racesCompleted: driver.racesCompleted,
|
||||
wins: driver.wins,
|
||||
podiums: driver.podiums,
|
||||
isActive: driver.isActive,
|
||||
rank: driver.rank,
|
||||
avatarUrl: driver.avatarUrl,
|
||||
})),
|
||||
totalRaces: output.totalRaces,
|
||||
totalWins: output.totalWins,
|
||||
activeCount: output.activeCount,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,30 +13,37 @@ import { LeagueAdminDTO } from './dtos/LeagueAdminDTO';
|
||||
import { LeagueAdminPermissionsDTO } from './dtos/LeagueAdminPermissionsDTO';
|
||||
import { LeagueAdminProtestsDTO } from './dtos/LeagueAdminProtestsDTO';
|
||||
import { LeagueMembershipsDTO } from './dtos/LeagueMembershipsDTO';
|
||||
import { LeagueOwnerSummaryDTO } from './dtos/LeagueOwnerSummaryDTO';
|
||||
import { LeagueScheduleDTO } from './dtos/LeagueScheduleDTO';
|
||||
import { LeagueSeasonSummaryDTO } from './dtos/LeagueSeasonSummaryDTO';
|
||||
import { LeagueConfigFormModelDTO } from './dtos/LeagueConfigFormModelDTO';
|
||||
import { LeagueStatsDTO } from './dtos/LeagueStatsDTO';
|
||||
import { LeagueStandingsDTO } from './dtos/LeagueStandingsDTO';
|
||||
import { GetLeagueWalletOutputDTO } from './dtos/GetLeagueWalletOutputDTO';
|
||||
import { WithdrawFromLeagueWalletInputDTO } from './dtos/WithdrawFromLeagueWalletInputDTO';
|
||||
import { WithdrawFromLeagueWalletOutputDTO } from './dtos/WithdrawFromLeagueWalletOutputDTO';
|
||||
import { GetSeasonSponsorshipsOutputDTO } from './dtos/GetSeasonSponsorshipsOutputDTO';
|
||||
import { GetLeagueRacesOutputDTO } from './dtos/GetLeagueRacesOutputDTO';
|
||||
import { RejectJoinRequestOutputDTO } from './dtos/RejectJoinRequestOutputDTO';
|
||||
import { RemoveLeagueMemberOutputDTO } from './dtos/RemoveLeagueMemberOutputDTO';
|
||||
import { TransferLeagueOwnershipOutputDTO } from './dtos/TransferLeagueOwnershipOutputDTO';
|
||||
import { UpdateLeagueMemberRoleOutputDTO } from './dtos/UpdateLeagueMemberRoleOutputDTO';
|
||||
import { LeagueJoinRequestWithDriverDTO } from './dtos/LeagueJoinRequestWithDriverDTO';
|
||||
import { ApproveLeagueJoinRequestDTO } from './dtos/ApproveLeagueJoinRequestDTO';
|
||||
|
||||
// Core imports for view models
|
||||
import type { LeagueScoringConfigViewModel } from './presenters/LeagueScoringConfigPresenter';
|
||||
import type { LeagueScoringPresetsViewModel } from './presenters/LeagueScoringPresetsPresenter';
|
||||
import type { AllLeaguesWithCapacityDTO as AllLeaguesWithCapacityViewModel } from '../dtos/AllLeaguesWithCapacityDTO';
|
||||
import type { GetLeagueJoinRequestsViewModel } from '@core/racing/application/presenters/IGetLeagueJoinRequestsPresenter';
|
||||
import type { AllLeaguesWithCapacityDTO as AllLeaguesWithCapacityViewModel } from './dtos/AllLeaguesWithCapacityDTO';
|
||||
import { TotalLeaguesDTO } from './dtos/TotalLeaguesDTO';
|
||||
import type { ApproveLeagueJoinRequestDTO } from './dtos/ApproveLeagueJoinRequestDTO';
|
||||
import type { JoinLeagueOutputDTO } from './dtos/JoinLeagueOutputDTO';
|
||||
import type { GetLeagueAdminPermissionsViewModel } from '@core/racing/application/presenters/IGetLeagueAdminPermissionsPresenter';
|
||||
import type { CreateLeagueViewModel } from './dtos/CreateLeagueDTO';
|
||||
|
||||
// Core imports
|
||||
import type { Logger } from '@core/shared/application/Logger';
|
||||
|
||||
// Use cases
|
||||
import { GetLeagueStandingsUseCase } from '@core/league/application/use-cases/GetLeagueStandingsUseCase';
|
||||
import { GetLeagueStandingsUseCase } from '@core/racing/application/use-cases/GetLeagueStandingsUseCase';
|
||||
import { ApproveLeagueJoinRequestUseCase } from '@core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase';
|
||||
import { CreateLeagueWithSeasonAndScoringUseCase } from '@core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase';
|
||||
import { GetAllLeaguesWithCapacityUseCase } from '@core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase';
|
||||
@@ -67,22 +74,26 @@ import { AllLeaguesWithCapacityPresenter } from './presenters/AllLeaguesWithCapa
|
||||
import { TotalLeaguesPresenter } from './presenters/TotalLeaguesPresenter';
|
||||
import { LeagueScoringConfigPresenter } from './presenters/LeagueScoringConfigPresenter';
|
||||
import { LeagueScoringPresetsPresenter } from './presenters/LeagueScoringPresetsPresenter';
|
||||
import { mapApproveLeagueJoinRequestPortToDTO } from './presenters/ApproveLeagueJoinRequestPresenter';
|
||||
import { ApproveLeagueJoinRequestPresenter } from './presenters/ApproveLeagueJoinRequestPresenter';
|
||||
import { GetLeagueAdminPermissionsPresenter } from './presenters/GetLeagueAdminPermissionsPresenter';
|
||||
import { mapGetLeagueOwnerSummaryOutputPortToDTO } from './presenters/GetLeagueOwnerSummaryPresenter';
|
||||
import { GetLeagueOwnerSummaryPresenter } from './presenters/GetLeagueOwnerSummaryPresenter';
|
||||
import { LeagueJoinRequestsPresenter } from './presenters/LeagueJoinRequestsPresenter';
|
||||
import { mapGetLeagueScheduleOutputPortToDTO, mapGetLeagueScheduleOutputPortToRaceDTOs } from './presenters/LeagueSchedulePresenter';
|
||||
import { LeagueSchedulePresenter, LeagueRacesPresenter } from './presenters/LeagueSchedulePresenter';
|
||||
import { LeagueStatsPresenter } from './presenters/LeagueStatsPresenter';
|
||||
import { mapRejectLeagueJoinRequestOutputPortToDTO } from './presenters/RejectLeagueJoinRequestPresenter';
|
||||
import { mapRemoveLeagueMemberOutputPortToDTO } from './presenters/RemoveLeagueMemberPresenter';
|
||||
import { mapUpdateLeagueMemberRoleOutputPortToDTO } from './presenters/UpdateLeagueMemberRolePresenter';
|
||||
import { RejectLeagueJoinRequestPresenter } from './presenters/RejectLeagueJoinRequestPresenter';
|
||||
import { RemoveLeagueMemberPresenter } from './presenters/RemoveLeagueMemberPresenter';
|
||||
import { UpdateLeagueMemberRolePresenter } from './presenters/UpdateLeagueMemberRolePresenter';
|
||||
import { CreateLeaguePresenter } from './presenters/CreateLeaguePresenter';
|
||||
import { mapJoinLeagueOutputPortToDTO } from './presenters/JoinLeaguePresenter';
|
||||
import { mapTransferLeagueOwnershipOutputPortToDTO } from './presenters/TransferLeagueOwnershipPresenter';
|
||||
import { mapGetLeagueProtestsOutputPortToDTO } from './presenters/GetLeagueProtestsPresenter';
|
||||
import { mapGetLeagueSeasonsOutputPortToDTO } from './presenters/GetLeagueSeasonsPresenter';
|
||||
import { JoinLeaguePresenter } from './presenters/JoinLeaguePresenter';
|
||||
import { TransferLeagueOwnershipPresenter } from './presenters/TransferLeagueOwnershipPresenter';
|
||||
import { GetLeagueProtestsPresenter } from './presenters/GetLeagueProtestsPresenter';
|
||||
import { GetLeagueSeasonsPresenter } from './presenters/GetLeagueSeasonsPresenter';
|
||||
import { LeagueConfigPresenter } from './presenters/LeagueConfigPresenter';
|
||||
import { LeagueStandingsPresenter } from './presenters/LeagueStandingsPresenter';
|
||||
import { GetLeagueMembershipsPresenter } from './presenters/GetLeagueMembershipsPresenter';
|
||||
import { LeagueOwnerSummaryPresenter } from './presenters/LeagueOwnerSummaryPresenter';
|
||||
import { LeagueAdminPresenter } from './presenters/LeagueAdminPresenter';
|
||||
import { GetSeasonSponsorshipsPresenter } from './presenters/GetSeasonSponsorshipsPresenter';
|
||||
// Tokens
|
||||
import { LOGGER_TOKEN } from './LeagueProviders';
|
||||
|
||||
@@ -140,7 +151,7 @@ export class LeagueService {
|
||||
return presenter.getViewModel()!;
|
||||
}
|
||||
|
||||
async getLeagueJoinRequests(leagueId: string): Promise<GetLeagueJoinRequestsViewModel> {
|
||||
async getLeagueJoinRequests(leagueId: string): Promise<LeagueJoinRequestWithDriverDTO[]> {
|
||||
this.logger.debug(`[LeagueService] Fetching join requests for league: ${leagueId}.`);
|
||||
const result = await this.getLeagueJoinRequestsUseCase.execute({ leagueId });
|
||||
if (result.isErr()) {
|
||||
@@ -148,7 +159,7 @@ export class LeagueService {
|
||||
}
|
||||
const presenter = new LeagueJoinRequestsPresenter();
|
||||
presenter.present(result.unwrap());
|
||||
return presenter.getViewModel();
|
||||
return presenter.getViewModel()!.joinRequests;
|
||||
}
|
||||
|
||||
async approveLeagueJoinRequest(input: ApproveJoinRequestInputDTO): Promise<ApproveLeagueJoinRequestDTO> {
|
||||
@@ -157,19 +168,27 @@ export class LeagueService {
|
||||
if (result.isErr()) {
|
||||
throw new Error(result.unwrapErr().code);
|
||||
}
|
||||
return mapApproveLeagueJoinRequestPortToDTO(result.unwrap());
|
||||
const presenter = new ApproveLeagueJoinRequestPresenter();
|
||||
presenter.present(result.unwrap());
|
||||
return presenter.getViewModel()!;
|
||||
}
|
||||
|
||||
async rejectLeagueJoinRequest(input: RejectJoinRequestInputDTO): Promise<RejectJoinRequestOutputDTO> {
|
||||
this.logger.debug('Rejecting join request:', input);
|
||||
const result = await this.rejectLeagueJoinRequestUseCase.execute({ requestId: input.requestId });
|
||||
if (result.isErr()) {
|
||||
throw new Error(result.unwrapErr().code);
|
||||
const error = result.unwrapErr();
|
||||
return {
|
||||
success: false,
|
||||
error: error.code,
|
||||
};
|
||||
}
|
||||
return mapRejectLeagueJoinRequestOutputPortToDTO(result.unwrap());
|
||||
const presenter = new RejectLeagueJoinRequestPresenter();
|
||||
presenter.present(result.unwrap());
|
||||
return presenter.getViewModel()!;
|
||||
}
|
||||
|
||||
async getLeagueAdminPermissions(query: GetLeagueAdminPermissionsInputDTO): Promise<GetLeagueAdminPermissionsViewModel> {
|
||||
async getLeagueAdminPermissions(query: GetLeagueAdminPermissionsInputDTO): Promise<LeagueAdminPermissionsDTO> {
|
||||
this.logger.debug('Getting league admin permissions', { query });
|
||||
const result = await this.getLeagueAdminPermissionsUseCase.execute({ leagueId: query.leagueId, performerDriverId: query.performerDriverId });
|
||||
// This use case never errors
|
||||
@@ -182,27 +201,41 @@ export class LeagueService {
|
||||
this.logger.debug('Removing league member', { leagueId: input.leagueId, targetDriverId: input.targetDriverId });
|
||||
const result = await this.removeLeagueMemberUseCase.execute({ leagueId: input.leagueId, targetDriverId: input.targetDriverId });
|
||||
if (result.isErr()) {
|
||||
throw new Error(result.unwrapErr().code);
|
||||
const error = result.unwrapErr();
|
||||
return {
|
||||
success: false,
|
||||
error: error.code,
|
||||
};
|
||||
}
|
||||
return mapRemoveLeagueMemberOutputPortToDTO(result.unwrap());
|
||||
const presenter = new RemoveLeagueMemberPresenter();
|
||||
presenter.present(result.unwrap());
|
||||
return presenter.getViewModel()!;
|
||||
}
|
||||
|
||||
async updateLeagueMemberRole(input: UpdateLeagueMemberRoleInputDTO): Promise<UpdateLeagueMemberRoleOutputDTO> {
|
||||
this.logger.debug('Updating league member role', { leagueId: input.leagueId, targetDriverId: input.targetDriverId, newRole: input.newRole });
|
||||
const result = await this.updateLeagueMemberRoleUseCase.execute({ leagueId: input.leagueId, targetDriverId: input.targetDriverId, newRole: input.newRole });
|
||||
if (result.isErr()) {
|
||||
throw new Error(result.unwrapErr().code);
|
||||
const error = result.unwrapErr();
|
||||
return {
|
||||
success: false,
|
||||
error: error.code,
|
||||
};
|
||||
}
|
||||
return mapUpdateLeagueMemberRoleOutputPortToDTO(result.unwrap());
|
||||
const presenter = new UpdateLeagueMemberRolePresenter();
|
||||
presenter.present(result.unwrap());
|
||||
return presenter.getViewModel()!;
|
||||
}
|
||||
|
||||
async getLeagueOwnerSummary(query: GetLeagueOwnerSummaryQueryDTO): Promise<LeagueOwnerSummaryDTO | null> {
|
||||
async getLeagueOwnerSummary(query: GetLeagueOwnerSummaryQueryDTO): Promise<LeagueOwnerSummaryDTO> {
|
||||
this.logger.debug('Getting league owner summary:', query);
|
||||
const result = await this.getLeagueOwnerSummaryUseCase.execute({ ownerId: query.ownerId });
|
||||
if (result.isErr()) {
|
||||
throw new Error(result.unwrapErr().code);
|
||||
}
|
||||
return mapGetLeagueOwnerSummaryOutputPortToDTO(result.unwrap());
|
||||
const presenter = new GetLeagueOwnerSummaryPresenter();
|
||||
presenter.present(result.unwrap());
|
||||
return presenter.getViewModel()!;
|
||||
}
|
||||
|
||||
async getLeagueFullConfig(query: GetLeagueAdminConfigQueryDTO): Promise<LeagueConfigFormModelDTO | null> {
|
||||
@@ -229,7 +262,9 @@ export class LeagueService {
|
||||
if (result.isErr()) {
|
||||
throw new Error(result.unwrapErr().code);
|
||||
}
|
||||
return mapGetLeagueProtestsOutputPortToDTO(result.unwrap());
|
||||
const presenter = new GetLeagueProtestsPresenter();
|
||||
presenter.present(result.unwrap());
|
||||
return presenter.getViewModel()!;
|
||||
}
|
||||
|
||||
async getLeagueSeasons(query: GetLeagueSeasonsQueryDTO): Promise<LeagueSeasonSummaryDTO[]> {
|
||||
@@ -238,7 +273,9 @@ export class LeagueService {
|
||||
if (result.isErr()) {
|
||||
throw new Error(result.unwrapErr().code);
|
||||
}
|
||||
return mapGetLeagueSeasonsOutputPortToDTO(result.unwrap());
|
||||
const presenter = new GetLeagueSeasonsPresenter();
|
||||
presenter.present(result.unwrap());
|
||||
return presenter.getViewModel()!;
|
||||
}
|
||||
|
||||
async getLeagueMemberships(leagueId: string): Promise<LeagueMembershipsDTO> {
|
||||
@@ -249,7 +286,7 @@ export class LeagueService {
|
||||
}
|
||||
const presenter = new GetLeagueMembershipsPresenter();
|
||||
presenter.present(result.unwrap());
|
||||
return presenter.getViewModel().memberships as LeagueMembershipsDTO;
|
||||
return presenter.getViewModel()!.memberships;
|
||||
}
|
||||
|
||||
async getLeagueStandings(leagueId: string): Promise<LeagueStandingsDTO> {
|
||||
@@ -260,7 +297,7 @@ export class LeagueService {
|
||||
}
|
||||
const presenter = new LeagueStandingsPresenter();
|
||||
presenter.present(result.unwrap());
|
||||
return presenter.getViewModel();
|
||||
return presenter.getViewModel()!;
|
||||
}
|
||||
|
||||
async getLeagueSchedule(leagueId: string): Promise<LeagueScheduleDTO> {
|
||||
@@ -279,7 +316,9 @@ export class LeagueService {
|
||||
? leagueConfigResult.unwrap().league.name.toString()
|
||||
: undefined;
|
||||
|
||||
return mapGetLeagueScheduleOutputPortToDTO(scheduleResult.unwrap(), leagueName);
|
||||
const presenter = new LeagueSchedulePresenter();
|
||||
presenter.present(scheduleResult.unwrap(), leagueName);
|
||||
return presenter.getViewModel()!;
|
||||
}
|
||||
|
||||
async getLeagueStats(leagueId: string): Promise<LeagueStatsDTO> {
|
||||
@@ -315,7 +354,9 @@ export class LeagueService {
|
||||
throw new Error(ownerSummaryResult.unwrapErr().code);
|
||||
}
|
||||
|
||||
const ownerSummary = mapGetLeagueOwnerSummaryOutputPortToDTO(ownerSummaryResult.unwrap());
|
||||
const ownerSummaryPresenter = new GetLeagueOwnerSummaryPresenter();
|
||||
ownerSummaryPresenter.present(ownerSummaryResult.unwrap());
|
||||
const ownerSummary = ownerSummaryPresenter.getViewModel()!;
|
||||
|
||||
const configPresenter = new LeagueConfigPresenter();
|
||||
configPresenter.present(fullConfig);
|
||||
@@ -323,7 +364,7 @@ export class LeagueService {
|
||||
|
||||
const adminPresenter = new LeagueAdminPresenter();
|
||||
adminPresenter.present({
|
||||
joinRequests: joinRequests.joinRequests,
|
||||
joinRequests: joinRequests,
|
||||
ownerSummary,
|
||||
config: configForm,
|
||||
protests,
|
||||
@@ -358,7 +399,7 @@ export class LeagueService {
|
||||
}
|
||||
const presenter = new CreateLeaguePresenter();
|
||||
presenter.present(result.unwrap());
|
||||
return presenter.getViewModel();
|
||||
return presenter.getViewModel()!;
|
||||
}
|
||||
|
||||
async getLeagueScoringConfig(leagueId: string): Promise<LeagueScoringConfigViewModel | null> {
|
||||
@@ -371,7 +412,7 @@ export class LeagueService {
|
||||
this.logger.error('Error getting league scoring config', new Error(result.unwrapErr().code));
|
||||
return null;
|
||||
}
|
||||
await presenter.present(result.unwrap());
|
||||
presenter.present(result.unwrap());
|
||||
return presenter.getViewModel();
|
||||
} catch (error) {
|
||||
this.logger.error('Error getting league scoring config', error instanceof Error ? error : new Error(String(error)));
|
||||
@@ -403,7 +444,9 @@ export class LeagueService {
|
||||
error: error.code,
|
||||
};
|
||||
}
|
||||
return mapJoinLeagueOutputPortToDTO(result.unwrap());
|
||||
const presenter = new JoinLeaguePresenter();
|
||||
presenter.present(result.unwrap());
|
||||
return presenter.getViewModel()!;
|
||||
}
|
||||
|
||||
async transferLeagueOwnership(leagueId: string, currentOwnerId: string, newOwnerId: string): Promise<TransferLeagueOwnershipOutputDTO> {
|
||||
@@ -417,7 +460,9 @@ export class LeagueService {
|
||||
error: error.code,
|
||||
};
|
||||
}
|
||||
return mapTransferLeagueOwnershipOutputPortToDTO(result.unwrap());
|
||||
const presenter = new TransferLeagueOwnershipPresenter();
|
||||
presenter.present(result.unwrap());
|
||||
return presenter.getViewModel()!;
|
||||
}
|
||||
|
||||
async getSeasonSponsorships(seasonId: string): Promise<GetSeasonSponsorshipsOutputDTO> {
|
||||
@@ -428,11 +473,9 @@ export class LeagueService {
|
||||
throw new Error(result.unwrapErr().code);
|
||||
}
|
||||
|
||||
const value = result.unwrap();
|
||||
|
||||
return {
|
||||
sponsorships: value?.sponsorships ?? [],
|
||||
};
|
||||
const presenter = new GetSeasonSponsorshipsPresenter();
|
||||
presenter.present(result.unwrap());
|
||||
return presenter.getViewModel()!;
|
||||
}
|
||||
|
||||
async getRaces(leagueId: string): Promise<GetLeagueRacesOutputDTO> {
|
||||
@@ -443,10 +486,11 @@ export class LeagueService {
|
||||
throw new Error(result.unwrapErr().code);
|
||||
}
|
||||
|
||||
const races = mapGetLeagueScheduleOutputPortToRaceDTOs(result.unwrap());
|
||||
const presenter = new LeagueRacesPresenter();
|
||||
presenter.present(result.unwrap());
|
||||
|
||||
return {
|
||||
races,
|
||||
races: presenter.getViewModel()!,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -454,7 +498,7 @@ export class LeagueService {
|
||||
this.logger.debug('Getting league wallet', { leagueId });
|
||||
const result = await this.getLeagueWalletUseCase.execute({ leagueId });
|
||||
if (result.isErr()) {
|
||||
throw new Error(result.unwrapErr().message);
|
||||
throw new Error(result.unwrapErr().code);
|
||||
}
|
||||
return result.unwrap();
|
||||
}
|
||||
@@ -471,9 +515,9 @@ export class LeagueService {
|
||||
if (result.isErr()) {
|
||||
const error = result.unwrapErr();
|
||||
if (error.code === 'WITHDRAWAL_NOT_ALLOWED') {
|
||||
return { success: false, message: error.message };
|
||||
return { success: false, message: error.code };
|
||||
}
|
||||
throw new Error(error.message);
|
||||
throw new Error(error.code);
|
||||
}
|
||||
return result.unwrap();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, IsDate, IsOptional, ValidateNested } from 'class-validator';
|
||||
import { IsString, IsDate, IsOptional } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { DriverDto } from '../../driver/dto/DriverDto';
|
||||
|
||||
export class LeagueJoinRequestDTO {
|
||||
@ApiProperty()
|
||||
@@ -26,9 +25,13 @@ export class LeagueJoinRequestDTO {
|
||||
@IsString()
|
||||
message?: string;
|
||||
|
||||
@ApiProperty({ type: () => DriverDto, required: false })
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
type: () => Object,
|
||||
})
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => DriverDto)
|
||||
driver?: DriverDto;
|
||||
driver?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsBoolean } from 'class-validator';
|
||||
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class RemoveLeagueMemberOutputDTO {
|
||||
@ApiProperty()
|
||||
@IsBoolean()
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
error?: string;
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsBoolean } from 'class-validator';
|
||||
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateLeagueMemberRoleOutputDTO {
|
||||
@ApiProperty()
|
||||
@IsBoolean()
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
error?: string;
|
||||
}
|
||||
@@ -1,6 +1,18 @@
|
||||
import type { ApproveLeagueJoinRequestResultPort } from '@core/racing/application/ports/output/ApproveLeagueJoinRequestResultPort';
|
||||
import type { ApproveLeagueJoinRequestDTO } from '../dtos/ApproveLeagueJoinRequestDTO';
|
||||
|
||||
export function mapApproveLeagueJoinRequestPortToDTO(port: ApproveLeagueJoinRequestResultPort): ApproveLeagueJoinRequestDTO {
|
||||
return port;
|
||||
export class ApproveLeagueJoinRequestPresenter {
|
||||
private result: ApproveLeagueJoinRequestDTO | null = null;
|
||||
|
||||
reset() {
|
||||
this.result = null;
|
||||
}
|
||||
|
||||
present(output: ApproveLeagueJoinRequestResultPort) {
|
||||
this.result = output;
|
||||
}
|
||||
|
||||
getViewModel(): ApproveLeagueJoinRequestDTO | null {
|
||||
return this.result;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,34 @@
|
||||
import { GetLeagueOwnerSummaryOutputPort } from '@core/racing/application/ports/output/GetLeagueOwnerSummaryOutputPort';
|
||||
import { LeagueOwnerSummaryDTO } from '../dtos/LeagueOwnerSummaryDTO';
|
||||
|
||||
export function mapGetLeagueOwnerSummaryOutputPortToDTO(output: GetLeagueOwnerSummaryOutputPort): LeagueOwnerSummaryDTO | null {
|
||||
if (!output.summary) return null;
|
||||
export class GetLeagueOwnerSummaryPresenter {
|
||||
private result: LeagueOwnerSummaryDTO | null = null;
|
||||
|
||||
return {
|
||||
driver: {
|
||||
id: output.summary.driver.id,
|
||||
iracingId: output.summary.driver.iracingId,
|
||||
name: output.summary.driver.name,
|
||||
country: output.summary.driver.country,
|
||||
bio: output.summary.driver.bio,
|
||||
joinedAt: output.summary.driver.joinedAt,
|
||||
},
|
||||
rating: output.summary.rating,
|
||||
rank: output.summary.rank,
|
||||
};
|
||||
reset() {
|
||||
this.result = null;
|
||||
}
|
||||
|
||||
present(output: GetLeagueOwnerSummaryOutputPort) {
|
||||
if (!output.summary) {
|
||||
this.result = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.result = {
|
||||
driver: {
|
||||
id: output.summary.driver.id,
|
||||
iracingId: output.summary.driver.iracingId,
|
||||
name: output.summary.driver.name,
|
||||
country: output.summary.driver.country,
|
||||
bio: output.summary.driver.bio,
|
||||
joinedAt: output.summary.driver.joinedAt,
|
||||
},
|
||||
rating: output.summary.rating,
|
||||
rank: output.summary.rank,
|
||||
};
|
||||
}
|
||||
|
||||
getViewModel(): LeagueOwnerSummaryDTO | null {
|
||||
return this.result;
|
||||
}
|
||||
}
|
||||
@@ -20,49 +20,65 @@ function mapProtestStatus(status: ProtestOutputPort['status']): ProtestDTO['stat
|
||||
}
|
||||
}
|
||||
|
||||
export function mapGetLeagueProtestsOutputPortToDTO(output: GetLeagueProtestsOutputPort, leagueName?: string): LeagueAdminProtestsDTO {
|
||||
const protests: ProtestDTO[] = output.protests.map((protest) => {
|
||||
const race = output.racesById[protest.raceId];
|
||||
export class GetLeagueProtestsPresenter {
|
||||
private result: LeagueAdminProtestsDTO | null = null;
|
||||
|
||||
return {
|
||||
id: protest.id,
|
||||
leagueId: race?.leagueId,
|
||||
raceId: protest.raceId,
|
||||
protestingDriverId: protest.protestingDriverId,
|
||||
accusedDriverId: protest.accusedDriverId,
|
||||
submittedAt: new Date(protest.filedAt),
|
||||
description: protest.incident.description,
|
||||
status: mapProtestStatus(protest.status),
|
||||
};
|
||||
});
|
||||
reset() {
|
||||
this.result = null;
|
||||
}
|
||||
|
||||
const racesById: { [raceId: string]: RaceDTO } = {};
|
||||
for (const raceId in output.racesById) {
|
||||
const race = output.racesById[raceId];
|
||||
racesById[raceId] = {
|
||||
id: race.id,
|
||||
name: race.track,
|
||||
date: race.scheduledAt,
|
||||
leagueName,
|
||||
present(output: GetLeagueProtestsOutputPort, leagueName?: string) {
|
||||
const protests: ProtestDTO[] = output.protests.map((protest) => {
|
||||
const race = output.racesById[protest.raceId];
|
||||
|
||||
return {
|
||||
id: protest.id,
|
||||
leagueId: race?.leagueId || '',
|
||||
raceId: protest.raceId,
|
||||
protestingDriverId: protest.protestingDriverId,
|
||||
accusedDriverId: protest.accusedDriverId,
|
||||
submittedAt: new Date(protest.filedAt),
|
||||
description: protest.incident.description,
|
||||
status: mapProtestStatus(protest.status),
|
||||
};
|
||||
});
|
||||
|
||||
const racesById: { [raceId: string]: RaceDTO } = {};
|
||||
for (const raceId in output.racesById) {
|
||||
const race = output.racesById[raceId];
|
||||
if (race) {
|
||||
racesById[raceId] = {
|
||||
id: race.id,
|
||||
name: race.track,
|
||||
date: race.scheduledAt.toISOString(),
|
||||
leagueName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const driversById: { [driverId: string]: DriverDTO } = {};
|
||||
for (const driverId in output.driversById) {
|
||||
const driver = output.driversById[driverId];
|
||||
if (driver) {
|
||||
driversById[driverId] = {
|
||||
id: driver.id,
|
||||
iracingId: driver.iracingId,
|
||||
name: driver.name,
|
||||
country: driver.country,
|
||||
bio: driver.bio,
|
||||
joinedAt: driver.joinedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.result = {
|
||||
protests,
|
||||
racesById,
|
||||
driversById,
|
||||
};
|
||||
}
|
||||
|
||||
const driversById: { [driverId: string]: DriverDTO } = {};
|
||||
for (const driverId in output.driversById) {
|
||||
const driver = output.driversById[driverId];
|
||||
driversById[driverId] = {
|
||||
id: driver.id,
|
||||
iracingId: driver.iracingId,
|
||||
name: driver.name,
|
||||
country: driver.country,
|
||||
bio: driver.bio,
|
||||
joinedAt: driver.joinedAt,
|
||||
};
|
||||
getViewModel(): LeagueAdminProtestsDTO | null {
|
||||
return this.result;
|
||||
}
|
||||
|
||||
return {
|
||||
protests,
|
||||
racesById,
|
||||
driversById,
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,26 @@
|
||||
import { GetLeagueSeasonsOutputPort } from '@core/racing/application/ports/output/GetLeagueSeasonsOutputPort';
|
||||
import { LeagueSeasonSummaryDTO } from '../dtos/LeagueSeasonSummaryDTO';
|
||||
|
||||
export function mapGetLeagueSeasonsOutputPortToDTO(output: GetLeagueSeasonsOutputPort): LeagueSeasonSummaryDTO[] {
|
||||
return output.seasons.map(season => ({
|
||||
seasonId: season.seasonId,
|
||||
name: season.name,
|
||||
status: season.status,
|
||||
startDate: season.startDate,
|
||||
endDate: season.endDate,
|
||||
isPrimary: season.isPrimary,
|
||||
isParallelActive: season.isParallelActive,
|
||||
}));
|
||||
export class GetLeagueSeasonsPresenter {
|
||||
private result: LeagueSeasonSummaryDTO[] | null = null;
|
||||
|
||||
reset() {
|
||||
this.result = null;
|
||||
}
|
||||
|
||||
present(output: GetLeagueSeasonsOutputPort) {
|
||||
this.result = output.seasons.map(season => ({
|
||||
seasonId: season.seasonId,
|
||||
name: season.name,
|
||||
status: season.status,
|
||||
startDate: season.startDate,
|
||||
endDate: season.endDate,
|
||||
isPrimary: season.isPrimary,
|
||||
isParallelActive: season.isParallelActive,
|
||||
}));
|
||||
}
|
||||
|
||||
getViewModel(): LeagueSeasonSummaryDTO[] | null {
|
||||
return this.result;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,21 @@
|
||||
import type { JoinLeagueOutputPort } from '@core/racing/application/ports/output/JoinLeagueOutputPort';
|
||||
import type { JoinLeagueOutputDTO } from '../dtos/JoinLeagueOutputDTO';
|
||||
|
||||
export function mapJoinLeagueOutputPortToDTO(port: JoinLeagueOutputPort): JoinLeagueOutputDTO {
|
||||
return {
|
||||
success: true,
|
||||
membershipId: port.membershipId,
|
||||
};
|
||||
export class JoinLeaguePresenter {
|
||||
private result: JoinLeagueOutputDTO | null = null;
|
||||
|
||||
reset() {
|
||||
this.result = null;
|
||||
}
|
||||
|
||||
present(output: JoinLeagueOutputPort) {
|
||||
this.result = {
|
||||
success: true,
|
||||
membershipId: output.membershipId,
|
||||
};
|
||||
}
|
||||
|
||||
getViewModel(): JoinLeagueOutputDTO | null {
|
||||
return this.result;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,46 @@ import { GetLeagueScheduleOutputPort } from '@core/racing/application/ports/outp
|
||||
import { LeagueScheduleDTO } from '../dtos/LeagueScheduleDTO';
|
||||
import { RaceDTO } from '../../race/dtos/RaceDTO';
|
||||
|
||||
export function mapGetLeagueScheduleOutputPortToDTO(output: GetLeagueScheduleOutputPort, leagueName?: string): LeagueScheduleDTO {
|
||||
return {
|
||||
races: output.races.map<RaceDTO>(race => ({
|
||||
export class LeagueSchedulePresenter {
|
||||
private result: LeagueScheduleDTO | null = null;
|
||||
|
||||
reset() {
|
||||
this.result = null;
|
||||
}
|
||||
|
||||
present(output: GetLeagueScheduleOutputPort, leagueName?: string) {
|
||||
this.result = {
|
||||
races: output.races.map<RaceDTO>(race => ({
|
||||
id: race.id,
|
||||
name: race.name,
|
||||
date: race.scheduledAt.toISOString(),
|
||||
leagueName,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
getViewModel(): LeagueScheduleDTO | null {
|
||||
return this.result;
|
||||
}
|
||||
}
|
||||
|
||||
export class LeagueRacesPresenter {
|
||||
private result: RaceDTO[] | null = null;
|
||||
|
||||
reset() {
|
||||
this.result = null;
|
||||
}
|
||||
|
||||
present(output: GetLeagueScheduleOutputPort, leagueName?: string) {
|
||||
this.result = output.races.map<RaceDTO>(race => ({
|
||||
id: race.id,
|
||||
name: race.name,
|
||||
date: race.scheduledAt.toISOString(),
|
||||
leagueName,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
export function mapGetLeagueScheduleOutputPortToRaceDTOs(output: GetLeagueScheduleOutputPort, leagueName?: string): RaceDTO[] {
|
||||
return output.races.map<RaceDTO>(race => ({
|
||||
id: race.id,
|
||||
name: race.name,
|
||||
date: race.scheduledAt.toISOString(),
|
||||
leagueName,
|
||||
}));
|
||||
getViewModel(): RaceDTO[] | null {
|
||||
return this.result;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,21 @@
|
||||
import type { RejectLeagueJoinRequestOutputPort } from '@core/racing/application/ports/output/RejectLeagueJoinRequestOutputPort';
|
||||
import type { RejectJoinRequestOutputDTO } from '../dtos/RejectJoinRequestOutputDTO';
|
||||
|
||||
export function mapRejectLeagueJoinRequestOutputPortToDTO(port: RejectLeagueJoinRequestOutputPort): RejectJoinRequestOutputDTO {
|
||||
return {
|
||||
success: port.success,
|
||||
message: port.message,
|
||||
};
|
||||
export class RejectLeagueJoinRequestPresenter {
|
||||
private result: RejectJoinRequestOutputDTO | null = null;
|
||||
|
||||
reset() {
|
||||
this.result = null;
|
||||
}
|
||||
|
||||
present(output: RejectLeagueJoinRequestOutputPort) {
|
||||
this.result = {
|
||||
success: output.success,
|
||||
message: output.message,
|
||||
};
|
||||
}
|
||||
|
||||
getViewModel(): RejectJoinRequestOutputDTO | null {
|
||||
return this.result;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,20 @@
|
||||
import type { RemoveLeagueMemberOutputPort } from '@core/racing/application/ports/output/RemoveLeagueMemberOutputPort';
|
||||
import type { RemoveLeagueMemberOutputDTO } from '../dtos/RemoveLeagueMemberOutputDTO';
|
||||
|
||||
export function mapRemoveLeagueMemberOutputPortToDTO(port: RemoveLeagueMemberOutputPort): RemoveLeagueMemberOutputDTO {
|
||||
return {
|
||||
success: port.success,
|
||||
};
|
||||
export class RemoveLeagueMemberPresenter {
|
||||
private result: RemoveLeagueMemberOutputDTO | null = null;
|
||||
|
||||
reset() {
|
||||
this.result = null;
|
||||
}
|
||||
|
||||
present(output: RemoveLeagueMemberOutputPort) {
|
||||
this.result = {
|
||||
success: output.success,
|
||||
};
|
||||
}
|
||||
|
||||
getViewModel(): RemoveLeagueMemberOutputDTO | null {
|
||||
return this.result;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,20 @@
|
||||
import type { TransferLeagueOwnershipOutputPort } from '@core/racing/application/ports/output/TransferLeagueOwnershipOutputPort';
|
||||
import type { TransferLeagueOwnershipOutputDTO } from '../dtos/TransferLeagueOwnershipOutputDTO';
|
||||
|
||||
export function mapTransferLeagueOwnershipOutputPortToDTO(port: TransferLeagueOwnershipOutputPort): TransferLeagueOwnershipOutputDTO {
|
||||
return {
|
||||
success: port.success,
|
||||
};
|
||||
export class TransferLeagueOwnershipPresenter {
|
||||
private result: TransferLeagueOwnershipOutputDTO | null = null;
|
||||
|
||||
reset() {
|
||||
this.result = null;
|
||||
}
|
||||
|
||||
present(output: TransferLeagueOwnershipOutputPort) {
|
||||
this.result = {
|
||||
success: output.success,
|
||||
};
|
||||
}
|
||||
|
||||
getViewModel(): TransferLeagueOwnershipOutputDTO | null {
|
||||
return this.result;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,20 @@
|
||||
import type { UpdateLeagueMemberRoleOutputPort } from '@core/racing/application/ports/output/UpdateLeagueMemberRoleOutputPort';
|
||||
import type { UpdateLeagueMemberRoleOutputDTO } from '../dtos/UpdateLeagueMemberRoleOutputDTO';
|
||||
|
||||
export function mapUpdateLeagueMemberRoleOutputPortToDTO(port: UpdateLeagueMemberRoleOutputPort): UpdateLeagueMemberRoleOutputDTO {
|
||||
return {
|
||||
success: port.success,
|
||||
};
|
||||
export class UpdateLeagueMemberRolePresenter {
|
||||
private result: UpdateLeagueMemberRoleOutputDTO | null = null;
|
||||
|
||||
reset() {
|
||||
this.result = null;
|
||||
}
|
||||
|
||||
present(output: UpdateLeagueMemberRoleOutputPort) {
|
||||
this.result = {
|
||||
success: output.success,
|
||||
};
|
||||
}
|
||||
|
||||
getViewModel(): UpdateLeagueMemberRoleOutputDTO | null {
|
||||
return this.result;
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,8 @@ describe('MediaController', () => {
|
||||
describe('requestAvatarGeneration', () => {
|
||||
it('should request avatar generation and return 201 on success', async () => {
|
||||
const input: RequestAvatarGenerationInputDTO = { driverId: 'driver-123' };
|
||||
const result = { success: true, jobId: 'job-123' };
|
||||
service.requestAvatarGeneration.mockResolvedValue(result);
|
||||
const viewModel = { success: true, jobId: 'job-123' } as any;
|
||||
service.requestAvatarGeneration.mockResolvedValue({ viewModel } as any);
|
||||
|
||||
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
@@ -47,13 +47,13 @@ describe('MediaController', () => {
|
||||
|
||||
expect(service.requestAvatarGeneration).toHaveBeenCalledWith(input);
|
||||
expect(mockRes.status).toHaveBeenCalledWith(201);
|
||||
expect(mockRes.json).toHaveBeenCalledWith(result);
|
||||
expect(mockRes.json).toHaveBeenCalledWith(viewModel);
|
||||
});
|
||||
|
||||
it('should return 400 on failure', async () => {
|
||||
const input: RequestAvatarGenerationInputDTO = { driverId: 'driver-123' };
|
||||
const result = { success: false, error: 'Error' };
|
||||
service.requestAvatarGeneration.mockResolvedValue(result);
|
||||
const viewModel = { success: false, error: 'Error' } as any;
|
||||
service.requestAvatarGeneration.mockResolvedValue({ viewModel } as any);
|
||||
|
||||
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
@@ -63,7 +63,7 @@ describe('MediaController', () => {
|
||||
await controller.requestAvatarGeneration(input, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.json).toHaveBeenCalledWith(result);
|
||||
expect(mockRes.json).toHaveBeenCalledWith(viewModel);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,8 +71,8 @@ describe('MediaController', () => {
|
||||
it('should upload media and return 201 on success', async () => {
|
||||
const file: Express.Multer.File = { filename: 'file.jpg' } as Express.Multer.File;
|
||||
const input: UploadMediaInputDTO = { type: 'image' };
|
||||
const result = { success: true, mediaId: 'media-123' };
|
||||
service.uploadMedia.mockResolvedValue(result);
|
||||
const viewModel = { success: true, mediaId: 'media-123' } as any;
|
||||
service.uploadMedia.mockResolvedValue({ viewModel } as any);
|
||||
|
||||
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
@@ -83,15 +83,15 @@ describe('MediaController', () => {
|
||||
|
||||
expect(service.uploadMedia).toHaveBeenCalledWith({ ...input, file });
|
||||
expect(mockRes.status).toHaveBeenCalledWith(201);
|
||||
expect(mockRes.json).toHaveBeenCalledWith(result);
|
||||
expect(mockRes.json).toHaveBeenCalledWith(viewModel);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMedia', () => {
|
||||
it('should return media if found', async () => {
|
||||
const mediaId = 'media-123';
|
||||
const result = { id: mediaId, url: 'url' };
|
||||
service.getMedia.mockResolvedValue(result);
|
||||
const viewModel = { id: mediaId, url: 'url' } as any;
|
||||
service.getMedia.mockResolvedValue({ viewModel } as any);
|
||||
|
||||
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
@@ -102,12 +102,12 @@ describe('MediaController', () => {
|
||||
|
||||
expect(service.getMedia).toHaveBeenCalledWith(mediaId);
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
expect(mockRes.json).toHaveBeenCalledWith(result);
|
||||
expect(mockRes.json).toHaveBeenCalledWith(viewModel);
|
||||
});
|
||||
|
||||
it('should return 404 if not found', async () => {
|
||||
const mediaId = 'media-123';
|
||||
service.getMedia.mockResolvedValue(null);
|
||||
service.getMedia.mockResolvedValue({ viewModel: null } as any);
|
||||
|
||||
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
@@ -124,8 +124,8 @@ describe('MediaController', () => {
|
||||
describe('deleteMedia', () => {
|
||||
it('should delete media', async () => {
|
||||
const mediaId = 'media-123';
|
||||
const result = { success: true };
|
||||
service.deleteMedia.mockResolvedValue(result);
|
||||
const viewModel = { success: true } as any;
|
||||
service.deleteMedia.mockResolvedValue({ viewModel } as any);
|
||||
|
||||
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
@@ -136,7 +136,7 @@ describe('MediaController', () => {
|
||||
|
||||
expect(service.deleteMedia).toHaveBeenCalledWith(mediaId);
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
expect(mockRes.json).toHaveBeenCalledWith(result);
|
||||
expect(mockRes.json).toHaveBeenCalledWith(viewModel);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -29,11 +29,13 @@ export class MediaController {
|
||||
@Body() input: RequestAvatarGenerationInput,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const result = await this.mediaService.requestAvatarGeneration(input);
|
||||
if (result.success) {
|
||||
res.status(HttpStatus.CREATED).json(result);
|
||||
const presenter = await this.mediaService.requestAvatarGeneration(input);
|
||||
const viewModel = presenter.viewModel;
|
||||
|
||||
if (viewModel.success) {
|
||||
res.status(HttpStatus.CREATED).json(viewModel);
|
||||
} else {
|
||||
res.status(HttpStatus.BAD_REQUEST).json(result);
|
||||
res.status(HttpStatus.BAD_REQUEST).json(viewModel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,11 +49,13 @@ export class MediaController {
|
||||
@Body() input: UploadMediaInput,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const result = await this.mediaService.uploadMedia({ ...input, file });
|
||||
if (result.success) {
|
||||
res.status(HttpStatus.CREATED).json(result);
|
||||
const presenter = await this.mediaService.uploadMedia({ ...input, file });
|
||||
const viewModel = presenter.viewModel;
|
||||
|
||||
if (viewModel.success) {
|
||||
res.status(HttpStatus.CREATED).json(viewModel);
|
||||
} else {
|
||||
res.status(HttpStatus.BAD_REQUEST).json(result);
|
||||
res.status(HttpStatus.BAD_REQUEST).json(viewModel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,9 +67,11 @@ export class MediaController {
|
||||
@Param('mediaId') mediaId: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const result = await this.mediaService.getMedia(mediaId);
|
||||
if (result) {
|
||||
res.status(HttpStatus.OK).json(result);
|
||||
const presenter = await this.mediaService.getMedia(mediaId);
|
||||
const viewModel = presenter.viewModel;
|
||||
|
||||
if (viewModel) {
|
||||
res.status(HttpStatus.OK).json(viewModel);
|
||||
} else {
|
||||
res.status(HttpStatus.NOT_FOUND).json({ error: 'Media not found' });
|
||||
}
|
||||
@@ -79,10 +85,12 @@ export class MediaController {
|
||||
@Param('mediaId') mediaId: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const result = await this.mediaService.deleteMedia(mediaId);
|
||||
res.status(HttpStatus.OK).json(result);
|
||||
}
|
||||
const presenter = await this.mediaService.deleteMedia(mediaId);
|
||||
const viewModel = presenter.viewModel;
|
||||
|
||||
res.status(HttpStatus.OK).json(viewModel);
|
||||
}
|
||||
|
||||
@Get('avatar/:driverId')
|
||||
@ApiOperation({ summary: 'Get avatar for driver' })
|
||||
@ApiParam({ name: 'driverId', description: 'Driver ID' })
|
||||
@@ -91,14 +99,16 @@ export class MediaController {
|
||||
@Param('driverId') driverId: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const result = await this.mediaService.getAvatar(driverId);
|
||||
if (result) {
|
||||
res.status(HttpStatus.OK).json(result);
|
||||
const presenter = await this.mediaService.getAvatar(driverId);
|
||||
const viewModel = presenter.viewModel;
|
||||
|
||||
if (viewModel) {
|
||||
res.status(HttpStatus.OK).json(viewModel);
|
||||
} else {
|
||||
res.status(HttpStatus.NOT_FOUND).json({ error: 'Avatar not found' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Put('avatar/:driverId')
|
||||
@ApiOperation({ summary: 'Update avatar for driver' })
|
||||
@ApiParam({ name: 'driverId', description: 'Driver ID' })
|
||||
@@ -108,7 +118,9 @@ export class MediaController {
|
||||
@Body() input: UpdateAvatarInput,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const result = await this.mediaService.updateAvatar(driverId, input);
|
||||
res.status(HttpStatus.OK).json(result);
|
||||
const presenter = await this.mediaService.updateAvatar(driverId, input);
|
||||
const viewModel = presenter.viewModel;
|
||||
|
||||
res.status(HttpStatus.OK).json(viewModel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import type { RequestAvatarGenerationInputDTO } from './dtos/RequestAvatarGenerationInputDTO';
|
||||
import type { RequestAvatarGenerationOutputDTO } from './dtos/RequestAvatarGenerationOutputDTO';
|
||||
import type { UploadMediaInputDTO } from './dtos/UploadMediaInputDTO';
|
||||
import type { UploadMediaOutputDTO } from './dtos/UploadMediaOutputDTO';
|
||||
import type { GetMediaOutputDTO } from './dtos/GetMediaOutputDTO';
|
||||
import type { DeleteMediaOutputDTO } from './dtos/DeleteMediaOutputDTO';
|
||||
import type { GetAvatarOutputDTO } from './dtos/GetAvatarOutputDTO';
|
||||
import type { UpdateAvatarInputDTO } from './dtos/UpdateAvatarInputDTO';
|
||||
import type { UpdateAvatarOutputDTO } from './dtos/UpdateAvatarOutputDTO';
|
||||
import type { RacingSuitColor } from '@core/media/domain/types/AvatarGenerationRequest';
|
||||
|
||||
type RequestAvatarGenerationInput = RequestAvatarGenerationInputDTO;
|
||||
type RequestAvatarGenerationOutput = RequestAvatarGenerationOutputDTO;
|
||||
type UploadMediaInput = UploadMediaInputDTO;
|
||||
type UploadMediaOutput = UploadMediaOutputDTO;
|
||||
type GetMediaOutput = GetMediaOutputDTO;
|
||||
type DeleteMediaOutput = DeleteMediaOutputDTO;
|
||||
type GetAvatarOutput = GetAvatarOutputDTO;
|
||||
type UpdateAvatarInput = UpdateAvatarInputDTO;
|
||||
type UpdateAvatarOutput = UpdateAvatarOutputDTO;
|
||||
|
||||
// Use cases
|
||||
import { RequestAvatarGenerationUseCase } from '@core/media/application/use-cases/RequestAvatarGenerationUseCase';
|
||||
@@ -60,7 +48,7 @@ export class MediaService {
|
||||
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async requestAvatarGeneration(input: RequestAvatarGenerationInput): Promise<RequestAvatarGenerationOutput> {
|
||||
async requestAvatarGeneration(input: RequestAvatarGenerationInput): Promise<RequestAvatarGenerationPresenter> {
|
||||
this.logger.debug('[MediaService] Requesting avatar generation.');
|
||||
|
||||
const presenter = new RequestAvatarGenerationPresenter();
|
||||
@@ -69,10 +57,11 @@ export class MediaService {
|
||||
facePhotoData: input.facePhotoData,
|
||||
suitColor: input.suitColor as RacingSuitColor,
|
||||
}, presenter);
|
||||
return presenter.viewModel;
|
||||
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async uploadMedia(input: UploadMediaInput & { file: Express.Multer.File }): Promise<UploadMediaOutput> {
|
||||
async uploadMedia(input: UploadMediaInput & { file: Express.Multer.File }): Promise<UploadMediaPresenter> {
|
||||
this.logger.debug('[MediaService] Uploading media.');
|
||||
|
||||
const presenter = new UploadMediaPresenter();
|
||||
@@ -83,102 +72,49 @@ export class MediaService {
|
||||
metadata: input.metadata,
|
||||
}, presenter);
|
||||
|
||||
const result = presenter.viewModel;
|
||||
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
mediaId: result.mediaId!,
|
||||
url: result.url!,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
errorMessage: result.errorMessage || 'Upload failed',
|
||||
};
|
||||
}
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getMedia(mediaId: string): Promise<GetMediaOutput | null> {
|
||||
async getMedia(mediaId: string): Promise<GetMediaPresenter> {
|
||||
this.logger.debug(`[MediaService] Getting media: ${mediaId}`);
|
||||
|
||||
const presenter = new GetMediaPresenter();
|
||||
|
||||
await this.getMediaUseCase.execute({ mediaId }, presenter);
|
||||
|
||||
const result = presenter.viewModel;
|
||||
|
||||
if (result.success && result.media) {
|
||||
return {
|
||||
success: true,
|
||||
mediaId: result.media.id,
|
||||
filename: result.media.filename,
|
||||
originalName: result.media.originalName,
|
||||
mimeType: result.media.mimeType,
|
||||
size: result.media.size,
|
||||
url: result.media.url,
|
||||
type: result.media.type,
|
||||
uploadedBy: result.media.uploadedBy,
|
||||
uploadedAt: result.media.uploadedAt,
|
||||
metadata: result.media.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async deleteMedia(mediaId: string): Promise<DeleteMediaOutput> {
|
||||
async deleteMedia(mediaId: string): Promise<DeleteMediaPresenter> {
|
||||
this.logger.debug(`[MediaService] Deleting media: ${mediaId}`);
|
||||
|
||||
const presenter = new DeleteMediaPresenter();
|
||||
|
||||
await this.deleteMediaUseCase.execute({ mediaId }, presenter);
|
||||
|
||||
const result = presenter.viewModel;
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
errorMessage: result.errorMessage,
|
||||
};
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getAvatar(driverId: string): Promise<GetAvatarOutput | null> {
|
||||
async getAvatar(driverId: string): Promise<GetAvatarPresenter> {
|
||||
this.logger.debug(`[MediaService] Getting avatar for driver: ${driverId}`);
|
||||
|
||||
const presenter = new GetAvatarPresenter();
|
||||
|
||||
await this.getAvatarUseCase.execute({ driverId }, presenter);
|
||||
|
||||
const result = presenter.viewModel;
|
||||
|
||||
if (result.success && result.avatar) {
|
||||
return {
|
||||
success: true,
|
||||
avatarId: result.avatar.id,
|
||||
driverId: result.avatar.driverId,
|
||||
mediaUrl: result.avatar.mediaUrl,
|
||||
selectedAt: result.avatar.selectedAt,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async updateAvatar(driverId: string, input: UpdateAvatarInput): Promise<UpdateAvatarOutput> {
|
||||
async updateAvatar(driverId: string, input: UpdateAvatarInput): Promise<UpdateAvatarPresenter> {
|
||||
this.logger.debug(`[MediaService] Updating avatar for driver: ${driverId}`);
|
||||
|
||||
|
||||
const presenter = new UpdateAvatarPresenter();
|
||||
|
||||
|
||||
await this.updateAvatarUseCase.execute({
|
||||
driverId,
|
||||
mediaUrl: input.mediaUrl,
|
||||
}, presenter);
|
||||
|
||||
const result = presenter.viewModel;
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
errorMessage: result.errorMessage,
|
||||
};
|
||||
|
||||
return presenter;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { IDeleteMediaPresenter, DeleteMediaResult } from '@core/media/application/presenters/IDeleteMediaPresenter';
|
||||
import type { DeleteMediaOutputDTO } from '../dtos/DeleteMediaOutputDTO';
|
||||
|
||||
type DeleteMediaOutput = DeleteMediaOutputDTO;
|
||||
|
||||
export class DeleteMediaPresenter implements IDeleteMediaPresenter {
|
||||
private result: DeleteMediaResult | null = null;
|
||||
@@ -7,8 +10,12 @@ export class DeleteMediaPresenter implements IDeleteMediaPresenter {
|
||||
this.result = result;
|
||||
}
|
||||
|
||||
get viewModel(): DeleteMediaResult {
|
||||
get viewModel(): DeleteMediaOutput {
|
||||
if (!this.result) throw new Error('Presenter not presented');
|
||||
return this.result;
|
||||
|
||||
return {
|
||||
success: this.result.success,
|
||||
error: this.result.errorMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { IGetAvatarPresenter, GetAvatarResult } from '@core/media/application/presenters/IGetAvatarPresenter';
|
||||
import type { GetAvatarOutputDTO } from '../dtos/GetAvatarOutputDTO';
|
||||
|
||||
export type GetAvatarViewModel = GetAvatarOutputDTO | null;
|
||||
|
||||
export class GetAvatarPresenter implements IGetAvatarPresenter {
|
||||
private result: GetAvatarResult | null = null;
|
||||
@@ -7,8 +10,13 @@ export class GetAvatarPresenter implements IGetAvatarPresenter {
|
||||
this.result = result;
|
||||
}
|
||||
|
||||
get viewModel(): GetAvatarResult {
|
||||
if (!this.result) throw new Error('Presenter not presented');
|
||||
return this.result;
|
||||
get viewModel(): GetAvatarViewModel {
|
||||
if (!this.result || !this.result.success || !this.result.avatar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
avatarUrl: this.result.avatar.mediaUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { IGetMediaPresenter, GetMediaResult } from '@core/media/application/presenters/IGetMediaPresenter';
|
||||
import type { GetMediaOutputDTO } from '../dtos/GetMediaOutputDTO';
|
||||
|
||||
// The HTTP-facing DTO (or null when not found)
|
||||
export type GetMediaViewModel = GetMediaOutputDTO | null;
|
||||
|
||||
export class GetMediaPresenter implements IGetMediaPresenter {
|
||||
private result: GetMediaResult | null = null;
|
||||
@@ -7,8 +11,21 @@ export class GetMediaPresenter implements IGetMediaPresenter {
|
||||
this.result = result;
|
||||
}
|
||||
|
||||
get viewModel(): GetMediaResult {
|
||||
if (!this.result) throw new Error('Presenter not presented');
|
||||
return this.result;
|
||||
get viewModel(): GetMediaViewModel {
|
||||
if (!this.result || !this.result.success || !this.result.media) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const media = this.result.media;
|
||||
|
||||
return {
|
||||
id: media.id,
|
||||
url: media.url,
|
||||
type: media.type,
|
||||
// Best-effort mapping from arbitrary metadata
|
||||
category: (media.metadata as { category?: string } | undefined)?.category,
|
||||
uploadedAt: media.uploadedAt,
|
||||
size: media.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
import type { IUpdateAvatarPresenter, UpdateAvatarResult } from '@core/media/application/presenters/IUpdateAvatarPresenter';
|
||||
import type { UpdateAvatarOutputDTO } from '../dtos/UpdateAvatarOutputDTO';
|
||||
|
||||
type UpdateAvatarOutput = UpdateAvatarOutputDTO;
|
||||
|
||||
export class UpdateAvatarPresenter implements IUpdateAvatarPresenter {
|
||||
private result: UpdateAvatarResult | null = null;
|
||||
|
||||
|
||||
present(result: UpdateAvatarResult) {
|
||||
this.result = result;
|
||||
}
|
||||
|
||||
get viewModel(): UpdateAvatarResult {
|
||||
|
||||
get viewModel(): UpdateAvatarOutput {
|
||||
if (!this.result) throw new Error('Presenter not presented');
|
||||
return this.result;
|
||||
|
||||
return {
|
||||
success: this.result.success,
|
||||
error: this.result.errorMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { IUploadMediaPresenter, UploadMediaResult } from '@core/media/application/presenters/IUploadMediaPresenter';
|
||||
import type { UploadMediaOutputDTO } from '../dtos/UploadMediaOutputDTO';
|
||||
|
||||
type UploadMediaOutput = UploadMediaOutputDTO;
|
||||
|
||||
export class UploadMediaPresenter implements IUploadMediaPresenter {
|
||||
private result: UploadMediaResult | null = null;
|
||||
@@ -7,8 +10,20 @@ export class UploadMediaPresenter implements IUploadMediaPresenter {
|
||||
this.result = result;
|
||||
}
|
||||
|
||||
get viewModel(): UploadMediaResult {
|
||||
get viewModel(): UploadMediaOutput {
|
||||
if (!this.result) throw new Error('Presenter not presented');
|
||||
return this.result;
|
||||
|
||||
if (this.result.success) {
|
||||
return {
|
||||
success: true,
|
||||
mediaId: this.result.mediaId,
|
||||
url: this.result.url,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: this.result.errorMessage || 'Upload failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ describe('PaymentsController', () => {
|
||||
it('should return payments', async () => {
|
||||
const query: GetPaymentsQuery = { status: 'pending' };
|
||||
const result = { payments: [] };
|
||||
service.getPayments.mockResolvedValue(result);
|
||||
service.getPayments.mockResolvedValue({ viewModel: result } as any);
|
||||
|
||||
const response = await controller.getPayments(query);
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('PaymentsController', () => {
|
||||
it('should create payment', async () => {
|
||||
const input: CreatePaymentInput = { amount: 100, type: 'membership_fee', payerId: 'payer-123', payerType: 'driver', leagueId: 'league-123' };
|
||||
const result = { payment: { id: 'pay-123' } };
|
||||
service.createPayment.mockResolvedValue(result);
|
||||
service.createPayment.mockResolvedValue({ viewModel: result } as any);
|
||||
|
||||
const response = await controller.createPayment(input);
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('PaymentsController', () => {
|
||||
it('should update payment status', async () => {
|
||||
const input: UpdatePaymentStatusInput = { paymentId: 'pay-123', status: 'completed' };
|
||||
const result = { payment: { id: 'pay-123', status: 'completed' } };
|
||||
service.updatePaymentStatus.mockResolvedValue(result);
|
||||
service.updatePaymentStatus.mockResolvedValue({ viewModel: result } as any);
|
||||
|
||||
const response = await controller.updatePaymentStatus(input);
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('PaymentsController', () => {
|
||||
it('should return membership fees', async () => {
|
||||
const query: GetMembershipFeesQuery = { leagueId: 'league-123' };
|
||||
const result = { fees: [] };
|
||||
service.getMembershipFees.mockResolvedValue(result);
|
||||
service.getMembershipFees.mockResolvedValue({ viewModel: result } as any);
|
||||
|
||||
const response = await controller.getMembershipFees(query);
|
||||
|
||||
@@ -92,7 +92,7 @@ describe('PaymentsController', () => {
|
||||
it('should upsert membership fee', async () => {
|
||||
const input: UpsertMembershipFeeInput = { leagueId: 'league-123', amount: 50 };
|
||||
const result = { feeId: 'fee-123' };
|
||||
service.upsertMembershipFee.mockResolvedValue(result);
|
||||
service.upsertMembershipFee.mockResolvedValue({ viewModel: result } as any);
|
||||
|
||||
const response = await controller.upsertMembershipFee(input);
|
||||
|
||||
@@ -105,7 +105,7 @@ describe('PaymentsController', () => {
|
||||
it('should update member payment', async () => {
|
||||
const input: UpdateMemberPaymentInput = { memberId: 'member-123', paymentId: 'pay-123' };
|
||||
const result = { success: true };
|
||||
service.updateMemberPayment.mockResolvedValue(result);
|
||||
service.updateMemberPayment.mockResolvedValue({ viewModel: result } as any);
|
||||
|
||||
const response = await controller.updateMemberPayment(input);
|
||||
|
||||
@@ -118,7 +118,7 @@ describe('PaymentsController', () => {
|
||||
it('should return prizes', async () => {
|
||||
const query: GetPrizesQuery = { leagueId: 'league-123' };
|
||||
const result = { prizes: [] };
|
||||
service.getPrizes.mockResolvedValue(result);
|
||||
service.getPrizes.mockResolvedValue({ viewModel: result } as any);
|
||||
|
||||
const response = await controller.getPrizes(query);
|
||||
|
||||
@@ -131,7 +131,7 @@ describe('PaymentsController', () => {
|
||||
it('should create prize', async () => {
|
||||
const input: CreatePrizeInput = { name: 'Prize', amount: 100 };
|
||||
const result = { prizeId: 'prize-123' };
|
||||
service.createPrize.mockResolvedValue(result);
|
||||
service.createPrize.mockResolvedValue({ viewModel: result } as any);
|
||||
|
||||
const response = await controller.createPrize(input);
|
||||
|
||||
@@ -144,7 +144,7 @@ describe('PaymentsController', () => {
|
||||
it('should award prize', async () => {
|
||||
const input: AwardPrizeInput = { prizeId: 'prize-123', driverId: 'driver-123' };
|
||||
const result = { success: true };
|
||||
service.awardPrize.mockResolvedValue(result);
|
||||
service.awardPrize.mockResolvedValue({ viewModel: result } as any);
|
||||
|
||||
const response = await controller.awardPrize(input);
|
||||
|
||||
@@ -157,7 +157,7 @@ describe('PaymentsController', () => {
|
||||
it('should delete prize', async () => {
|
||||
const query: DeletePrizeInput = { prizeId: 'prize-123' };
|
||||
const result = { success: true };
|
||||
service.deletePrize.mockResolvedValue(result);
|
||||
service.deletePrize.mockResolvedValue({ viewModel: result } as any);
|
||||
|
||||
const response = await controller.deletePrize(query);
|
||||
|
||||
@@ -170,7 +170,7 @@ describe('PaymentsController', () => {
|
||||
it('should return wallet', async () => {
|
||||
const query: GetWalletQuery = { userId: 'user-123' };
|
||||
const result = { balance: 100 };
|
||||
service.getWallet.mockResolvedValue(result);
|
||||
service.getWallet.mockResolvedValue({ viewModel: result } as any);
|
||||
|
||||
const response = await controller.getWallet(query);
|
||||
|
||||
@@ -183,7 +183,7 @@ describe('PaymentsController', () => {
|
||||
it('should process wallet transaction', async () => {
|
||||
const input: ProcessWalletTransactionInput = { userId: 'user-123', amount: 50, type: 'deposit' };
|
||||
const result = { transactionId: 'tx-123' };
|
||||
service.processWalletTransaction.mockResolvedValue(result);
|
||||
service.processWalletTransaction.mockResolvedValue({ viewModel: result } as any);
|
||||
|
||||
const response = await controller.processWalletTransaction(input);
|
||||
|
||||
@@ -191,4 +191,4 @@ describe('PaymentsController', () => {
|
||||
expect(response).toEqual(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,8 @@ export class PaymentsController {
|
||||
@ApiOperation({ summary: 'Get payments based on filters' })
|
||||
@ApiResponse({ status: 200, description: 'List of payments', type: GetPaymentsOutput })
|
||||
async getPayments(@Query() query: GetPaymentsQuery): Promise<GetPaymentsOutput> {
|
||||
return this.paymentsService.getPayments(query);
|
||||
const presenter = await this.paymentsService.getPayments(query);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Post()
|
||||
@@ -20,21 +21,24 @@ export class PaymentsController {
|
||||
@ApiOperation({ summary: 'Create a new payment' })
|
||||
@ApiResponse({ status: 201, description: 'Payment created', type: CreatePaymentOutput })
|
||||
async createPayment(@Body() input: CreatePaymentInput): Promise<CreatePaymentOutput> {
|
||||
return this.paymentsService.createPayment(input);
|
||||
const presenter = await this.paymentsService.createPayment(input);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Patch('status')
|
||||
@ApiOperation({ summary: 'Update the status of a payment' })
|
||||
@ApiResponse({ status: 200, description: 'Payment status updated', type: UpdatePaymentStatusOutput })
|
||||
async updatePaymentStatus(@Body() input: UpdatePaymentStatusInput): Promise<UpdatePaymentStatusOutput> {
|
||||
return this.paymentsService.updatePaymentStatus(input);
|
||||
const presenter = await this.paymentsService.updatePaymentStatus(input);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Get('membership-fees')
|
||||
@ApiOperation({ summary: 'Get membership fees and member payments' })
|
||||
@ApiResponse({ status: 200, description: 'Membership fee configuration and member payments', type: GetMembershipFeesOutput })
|
||||
async getMembershipFees(@Query() query: GetMembershipFeesQuery): Promise<GetMembershipFeesOutput> {
|
||||
return this.paymentsService.getMembershipFees(query);
|
||||
const presenter = await this.paymentsService.getMembershipFees(query);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Post('membership-fees')
|
||||
@@ -42,20 +46,23 @@ export class PaymentsController {
|
||||
@ApiOperation({ summary: 'Create or update membership fee configuration' })
|
||||
@ApiResponse({ status: 201, description: 'Membership fee configuration created or updated', type: UpsertMembershipFeeOutput })
|
||||
async upsertMembershipFee(@Body() input: UpsertMembershipFeeInput): Promise<UpsertMembershipFeeOutput> {
|
||||
return this.paymentsService.upsertMembershipFee(input);
|
||||
const presenter = await this.paymentsService.upsertMembershipFee(input);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Patch('membership-fees/member-payment')
|
||||
@ApiOperation({ summary: 'Record or update a member payment' })
|
||||
@ApiResponse({ status: 200, description: 'Member payment recorded or updated', type: UpdateMemberPaymentOutput })
|
||||
async updateMemberPayment(@Body() input: UpdateMemberPaymentInput): Promise<UpdateMemberPaymentOutput> {
|
||||
return this.paymentsService.updateMemberPayment(input);
|
||||
const presenter = await this.paymentsService.updateMemberPayment(input);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
@Get('prizes')
|
||||
@ApiOperation({ summary: 'Get prizes for a league or season' })
|
||||
@ApiResponse({ status: 200, description: 'List of prizes', type: GetPrizesOutput })
|
||||
async getPrizes(@Query() query: GetPrizesQuery): Promise<GetPrizesOutput> {
|
||||
return this.paymentsService.getPrizes(query);
|
||||
const presenter = await this.paymentsService.getPrizes(query);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Post('prizes')
|
||||
@@ -63,27 +70,31 @@ export class PaymentsController {
|
||||
@ApiOperation({ summary: 'Create a new prize' })
|
||||
@ApiResponse({ status: 201, description: 'Prize created', type: CreatePrizeOutput })
|
||||
async createPrize(@Body() input: CreatePrizeInput): Promise<CreatePrizeOutput> {
|
||||
return this.paymentsService.createPrize(input);
|
||||
const presenter = await this.paymentsService.createPrize(input);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Patch('prizes/award')
|
||||
@ApiOperation({ summary: 'Award a prize to a driver' })
|
||||
@ApiResponse({ status: 200, description: 'Prize awarded', type: AwardPrizeOutput })
|
||||
async awardPrize(@Body() input: AwardPrizeInput): Promise<AwardPrizeOutput> {
|
||||
return this.paymentsService.awardPrize(input);
|
||||
const presenter = await this.paymentsService.awardPrize(input);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Delete('prizes')
|
||||
@ApiOperation({ summary: 'Delete a prize' })
|
||||
@ApiResponse({ status: 200, description: 'Prize deleted', type: DeletePrizeOutput })
|
||||
async deletePrize(@Query() query: DeletePrizeInput): Promise<DeletePrizeOutput> {
|
||||
return this.paymentsService.deletePrize(query);
|
||||
const presenter = await this.paymentsService.deletePrize(query);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
@Get('wallets')
|
||||
@ApiOperation({ summary: 'Get wallet information and transactions' })
|
||||
@ApiResponse({ status: 200, description: 'Wallet and transaction data', type: GetWalletOutput })
|
||||
async getWallet(@Query() query: GetWalletQuery): Promise<GetWalletOutput> {
|
||||
return this.paymentsService.getWallet(query);
|
||||
const presenter = await this.paymentsService.getWallet(query);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Post('wallets/transactions')
|
||||
@@ -91,6 +102,7 @@ export class PaymentsController {
|
||||
@ApiOperation({ summary: 'Process a wallet transaction (deposit or withdrawal)' })
|
||||
@ApiResponse({ status: 201, description: 'Wallet transaction processed', type: ProcessWalletTransactionOutput })
|
||||
async processWalletTransaction(@Body() input: ProcessWalletTransactionInput): Promise<ProcessWalletTransactionOutput> {
|
||||
return this.paymentsService.processWalletTransaction(input);
|
||||
const presenter = await this.paymentsService.processWalletTransaction(input);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,99 +92,99 @@ export class PaymentsService {
|
||||
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async getPayments(query: GetPaymentsQuery): Promise<GetPaymentsOutput> {
|
||||
async getPayments(query: GetPaymentsQuery): Promise<GetPaymentsPresenter> {
|
||||
this.logger.debug('[PaymentsService] Getting payments', { query });
|
||||
|
||||
const presenter = new GetPaymentsPresenter();
|
||||
await this.getPaymentsUseCase.execute(query, presenter);
|
||||
return presenter.viewModel;
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async createPayment(input: CreatePaymentInput): Promise<CreatePaymentOutput> {
|
||||
async createPayment(input: CreatePaymentInput): Promise<CreatePaymentPresenter> {
|
||||
this.logger.debug('[PaymentsService] Creating payment', { input });
|
||||
|
||||
const presenter = new CreatePaymentPresenter();
|
||||
await this.createPaymentUseCase.execute(input, presenter);
|
||||
return presenter.viewModel;
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async updatePaymentStatus(input: UpdatePaymentStatusInput): Promise<UpdatePaymentStatusOutput> {
|
||||
async updatePaymentStatus(input: UpdatePaymentStatusInput): Promise<UpdatePaymentStatusPresenter> {
|
||||
this.logger.debug('[PaymentsService] Updating payment status', { input });
|
||||
|
||||
const presenter = new UpdatePaymentStatusPresenter();
|
||||
await this.updatePaymentStatusUseCase.execute(input, presenter);
|
||||
return presenter.viewModel;
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getMembershipFees(query: GetMembershipFeesQuery): Promise<GetMembershipFeesOutput> {
|
||||
async getMembershipFees(query: GetMembershipFeesQuery): Promise<GetMembershipFeesPresenter> {
|
||||
this.logger.debug('[PaymentsService] Getting membership fees', { query });
|
||||
|
||||
const presenter = new GetMembershipFeesPresenter();
|
||||
await this.getMembershipFeesUseCase.execute(query, presenter);
|
||||
return presenter.viewModel;
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async upsertMembershipFee(input: UpsertMembershipFeeInput): Promise<UpsertMembershipFeeOutput> {
|
||||
async upsertMembershipFee(input: UpsertMembershipFeeInput): Promise<UpsertMembershipFeePresenter> {
|
||||
this.logger.debug('[PaymentsService] Upserting membership fee', { input });
|
||||
|
||||
const presenter = new UpsertMembershipFeePresenter();
|
||||
await this.upsertMembershipFeeUseCase.execute(input, presenter);
|
||||
return presenter.viewModel;
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async updateMemberPayment(input: UpdateMemberPaymentInput): Promise<UpdateMemberPaymentOutput> {
|
||||
async updateMemberPayment(input: UpdateMemberPaymentInput): Promise<UpdateMemberPaymentPresenter> {
|
||||
this.logger.debug('[PaymentsService] Updating member payment', { input });
|
||||
|
||||
const presenter = new UpdateMemberPaymentPresenter();
|
||||
await this.updateMemberPaymentUseCase.execute(input, presenter);
|
||||
return presenter.viewModel;
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getPrizes(query: GetPrizesQuery): Promise<GetPrizesOutput> {
|
||||
async getPrizes(query: GetPrizesQuery): Promise<GetPrizesPresenter> {
|
||||
this.logger.debug('[PaymentsService] Getting prizes', { query });
|
||||
|
||||
const presenter = new GetPrizesPresenter();
|
||||
await this.getPrizesUseCase.execute({ leagueId: query.leagueId!, seasonId: query.seasonId }, presenter);
|
||||
return presenter.viewModel;
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async createPrize(input: CreatePrizeInput): Promise<CreatePrizeOutput> {
|
||||
async createPrize(input: CreatePrizeInput): Promise<CreatePrizePresenter> {
|
||||
this.logger.debug('[PaymentsService] Creating prize', { input });
|
||||
|
||||
const presenter = new CreatePrizePresenter();
|
||||
await this.createPrizeUseCase.execute(input, presenter);
|
||||
return presenter.viewModel;
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async awardPrize(input: AwardPrizeInput): Promise<AwardPrizeOutput> {
|
||||
async awardPrize(input: AwardPrizeInput): Promise<AwardPrizePresenter> {
|
||||
this.logger.debug('[PaymentsService] Awarding prize', { input });
|
||||
|
||||
const presenter = new AwardPrizePresenter();
|
||||
await this.awardPrizeUseCase.execute(input, presenter);
|
||||
return presenter.viewModel;
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async deletePrize(input: DeletePrizeInput): Promise<DeletePrizeOutput> {
|
||||
async deletePrize(input: DeletePrizeInput): Promise<DeletePrizePresenter> {
|
||||
this.logger.debug('[PaymentsService] Deleting prize', { input });
|
||||
|
||||
const presenter = new DeletePrizePresenter();
|
||||
await this.deletePrizeUseCase.execute(input, presenter);
|
||||
return presenter.viewModel;
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getWallet(query: GetWalletQuery): Promise<GetWalletOutput> {
|
||||
async getWallet(query: GetWalletQuery): Promise<GetWalletPresenter> {
|
||||
this.logger.debug('[PaymentsService] Getting wallet', { query });
|
||||
|
||||
const presenter = new GetWalletPresenter();
|
||||
await this.getWalletUseCase.execute({ leagueId: query.leagueId! }, presenter);
|
||||
return presenter.viewModel;
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async processWalletTransaction(input: ProcessWalletTransactionInput): Promise<ProcessWalletTransactionOutput> {
|
||||
async processWalletTransaction(input: ProcessWalletTransactionInput): Promise<ProcessWalletTransactionPresenter> {
|
||||
this.logger.debug('[PaymentsService] Processing wallet transaction', { input });
|
||||
|
||||
const presenter = new ProcessWalletTransactionPresenter();
|
||||
await this.processWalletTransactionUseCase.execute(input, presenter);
|
||||
return presenter.viewModel;
|
||||
return presenter;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { vi } from 'vitest';
|
||||
import { vi, type MockedFunction } from 'vitest';
|
||||
import { ForbiddenException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||
import { ProtestsController } from './ProtestsController';
|
||||
import { RaceService } from '../race/RaceService';
|
||||
import { ProtestsService } from './ProtestsService';
|
||||
import { ReviewProtestCommandDTO } from '../race/dtos/ReviewProtestCommandDTO';
|
||||
import type { ReviewProtestPresenter } from './presenters/ReviewProtestPresenter';
|
||||
|
||||
describe('ProtestsController', () => {
|
||||
let controller: ProtestsController;
|
||||
let raceService: ReturnType<typeof vi.mocked<RaceService>>;
|
||||
let reviewProtestMock: MockedFunction<ProtestsService['reviewProtest']>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [ProtestsController],
|
||||
providers: [
|
||||
{
|
||||
provide: RaceService,
|
||||
provide: ProtestsService,
|
||||
useValue: {
|
||||
reviewProtest: vi.fn(),
|
||||
},
|
||||
@@ -22,18 +24,98 @@ describe('ProtestsController', () => {
|
||||
}).compile();
|
||||
|
||||
controller = module.get<ProtestsController>(ProtestsController);
|
||||
raceService = vi.mocked(module.get(RaceService));
|
||||
const service = module.get(ProtestsService);
|
||||
reviewProtestMock = vi.mocked(service.reviewProtest);
|
||||
});
|
||||
|
||||
const successPresenter = (viewModel: ReviewProtestPresenter['viewModel']): ReviewProtestPresenter => ({
|
||||
get viewModel() {
|
||||
return viewModel;
|
||||
},
|
||||
getViewModel: () => viewModel,
|
||||
reset: vi.fn(),
|
||||
presentSuccess: vi.fn(),
|
||||
presentError: vi.fn(),
|
||||
} as unknown as ReviewProtestPresenter);
|
||||
|
||||
describe('reviewProtest', () => {
|
||||
it('should review protest', async () => {
|
||||
it('should call service and not throw on success', async () => {
|
||||
const protestId = 'protest-123';
|
||||
const body: Omit<ReviewProtestCommandDTO, 'protestId'> = { decision: 'upheld', reason: 'Reason' };
|
||||
raceService.reviewProtest.mockResolvedValue(undefined);
|
||||
const body: Omit<ReviewProtestCommandDTO, 'protestId'> = {
|
||||
stewardId: 'steward-1',
|
||||
decision: 'uphold',
|
||||
decisionNotes: 'Reason',
|
||||
};
|
||||
|
||||
reviewProtestMock.mockResolvedValue(
|
||||
successPresenter({
|
||||
success: true,
|
||||
protestId,
|
||||
stewardId: body.stewardId,
|
||||
decision: body.decision,
|
||||
}),
|
||||
);
|
||||
|
||||
await controller.reviewProtest(protestId, body);
|
||||
|
||||
expect(raceService.reviewProtest).toHaveBeenCalledWith({ protestId, ...body });
|
||||
expect(reviewProtestMock).toHaveBeenCalledWith({ protestId, ...body });
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when protest is not found', async () => {
|
||||
const protestId = 'protest-123';
|
||||
const body: Omit<ReviewProtestCommandDTO, 'protestId'> = {
|
||||
stewardId: 'steward-1',
|
||||
decision: 'uphold',
|
||||
decisionNotes: 'Reason',
|
||||
};
|
||||
|
||||
reviewProtestMock.mockResolvedValue(
|
||||
successPresenter({
|
||||
success: false,
|
||||
errorCode: 'PROTEST_NOT_FOUND',
|
||||
message: 'Protest not found',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(controller.reviewProtest(protestId, body)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException when steward is not league admin', async () => {
|
||||
const protestId = 'protest-123';
|
||||
const body: Omit<ReviewProtestCommandDTO, 'protestId'> = {
|
||||
stewardId: 'steward-1',
|
||||
decision: 'uphold',
|
||||
decisionNotes: 'Reason',
|
||||
};
|
||||
|
||||
reviewProtestMock.mockResolvedValue(
|
||||
successPresenter({
|
||||
success: false,
|
||||
errorCode: 'NOT_LEAGUE_ADMIN',
|
||||
message: 'Not authorized',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(controller.reviewProtest(protestId, body)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('should throw InternalServerErrorException for unexpected error codes', async () => {
|
||||
const protestId = 'protest-123';
|
||||
const body: Omit<ReviewProtestCommandDTO, 'protestId'> = {
|
||||
stewardId: 'steward-1',
|
||||
decision: 'uphold',
|
||||
decisionNotes: 'Reason',
|
||||
};
|
||||
|
||||
reviewProtestMock.mockResolvedValue(
|
||||
successPresenter({
|
||||
success: false,
|
||||
errorCode: 'UNEXPECTED_ERROR',
|
||||
message: 'Unexpected',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(controller.reviewProtest(protestId, body)).rejects.toBeInstanceOf(InternalServerErrorException);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Controller, Post, Body, HttpCode, HttpStatus, Param } from '@nestjs/common';
|
||||
import { ApiTags, ApiResponse, ApiOperation, ApiParam } from '@nestjs/swagger';
|
||||
import { RaceService } from '../race/RaceService';
|
||||
import { Body, Controller, ForbiddenException, HttpCode, HttpStatus, InternalServerErrorException, NotFoundException, Param, Post } from '@nestjs/common';
|
||||
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { ProtestsService } from './ProtestsService';
|
||||
import { ReviewProtestCommandDTO } from '../race/dtos/ReviewProtestCommandDTO';
|
||||
|
||||
@ApiTags('protests')
|
||||
@Controller('protests')
|
||||
export class ProtestsController {
|
||||
constructor(private readonly raceService: RaceService) {}
|
||||
constructor(private readonly protestsService: ProtestsService) {}
|
||||
|
||||
@Post(':protestId/review')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -17,6 +17,20 @@ export class ProtestsController {
|
||||
@Param('protestId') protestId: string,
|
||||
@Body() body: Omit<ReviewProtestCommandDTO, 'protestId'>,
|
||||
): Promise<void> {
|
||||
return this.raceService.reviewProtest({ protestId, ...body });
|
||||
const presenter = await this.protestsService.reviewProtest({ protestId, ...body });
|
||||
const viewModel = presenter.viewModel;
|
||||
|
||||
if (!viewModel.success) {
|
||||
switch (viewModel.errorCode) {
|
||||
case 'PROTEST_NOT_FOUND':
|
||||
throw new NotFoundException(viewModel.message ?? 'Protest not found');
|
||||
case 'RACE_NOT_FOUND':
|
||||
throw new NotFoundException(viewModel.message ?? 'Race not found for protest');
|
||||
case 'NOT_LEAGUE_ADMIN':
|
||||
throw new ForbiddenException(viewModel.message ?? 'Steward is not authorized to review this protest');
|
||||
default:
|
||||
throw new InternalServerErrorException(viewModel.message ?? 'Failed to review protest');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
101
apps/api/src/domain/protests/ProtestsService.test.ts
Normal file
101
apps/api/src/domain/protests/ProtestsService.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { Logger } from '@core/shared/application/Logger';
|
||||
|
||||
// Use cases
|
||||
import { ReviewProtestUseCase } from '@core/racing/application/use-cases/ReviewProtestUseCase';
|
||||
|
||||
// Presenter
|
||||
import { ReviewProtestPresenter } from './presenters/ReviewProtestPresenter';
|
||||
|
||||
// Tokens
|
||||
import { LOGGER_TOKEN } from './ProtestsProviders';
|
||||
|
||||
@@ -19,13 +22,41 @@ export class ProtestsService {
|
||||
stewardId: string;
|
||||
decision: 'uphold' | 'dismiss';
|
||||
decisionNotes: string;
|
||||
}): Promise<void> {
|
||||
}): Promise<ReviewProtestPresenter> {
|
||||
this.logger.debug('[ProtestsService] Reviewing protest:', command);
|
||||
|
||||
const presenter = new ReviewProtestPresenter();
|
||||
const result = await this.reviewProtestUseCase.execute(command);
|
||||
|
||||
if (result.isErr()) {
|
||||
throw new Error(result.error.details.message || 'Failed to review protest');
|
||||
const error = result.unwrapErr();
|
||||
|
||||
let message: string;
|
||||
switch (error.code) {
|
||||
case 'PROTEST_NOT_FOUND':
|
||||
message = 'Protest not found';
|
||||
break;
|
||||
case 'RACE_NOT_FOUND':
|
||||
message = 'Race not found for protest';
|
||||
break;
|
||||
case 'NOT_LEAGUE_ADMIN':
|
||||
message = 'Steward is not authorized to review this protest';
|
||||
break;
|
||||
default:
|
||||
message = 'Failed to review protest';
|
||||
break;
|
||||
}
|
||||
|
||||
presenter.presentError(error.code, message);
|
||||
return presenter;
|
||||
}
|
||||
|
||||
presenter.presentSuccess({
|
||||
protestId: command.protestId,
|
||||
stewardId: command.stewardId,
|
||||
decision: command.decision,
|
||||
});
|
||||
|
||||
return presenter;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ describe('RaceController', () => {
|
||||
applyQuickPenalty: jest.fn(),
|
||||
applyPenalty: jest.fn(),
|
||||
requestProtestDefense: jest.fn(),
|
||||
};
|
||||
} as unknown as jest.Mocked<RaceService>;
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [RaceController],
|
||||
@@ -39,7 +39,7 @@ describe('RaceController', () => {
|
||||
}).compile();
|
||||
|
||||
controller = module.get<RaceController>(RaceController);
|
||||
service = module.get(RaceService);
|
||||
service = module.get(RaceService) as jest.Mocked<RaceService>;
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
@@ -47,28 +47,26 @@ describe('RaceController', () => {
|
||||
});
|
||||
|
||||
describe('getAllRaces', () => {
|
||||
it('should return all races', async () => {
|
||||
const mockResult = { races: [], totalCount: 0 };
|
||||
service.getAllRaces.mockResolvedValue(mockResult);
|
||||
it('should return all races view model', async () => {
|
||||
const mockViewModel = { races: [], filters: { statuses: [], leagues: [] } } as { races: unknown[]; filters: { statuses: unknown[]; leagues: unknown[] } };
|
||||
service.getAllRaces.mockResolvedValue({ viewModel: mockViewModel } as unknown as ReturnType<RaceService['getAllRaces']>);
|
||||
|
||||
const result = await controller.getAllRaces();
|
||||
|
||||
expect(service.getAllRaces).toHaveBeenCalled();
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(result).toEqual(mockViewModel);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTotalRaces', () => {
|
||||
it('should return total races count', async () => {
|
||||
const mockResult = { totalRaces: 5 };
|
||||
service.getTotalRaces.mockResolvedValue(mockResult);
|
||||
it('should return total races count view model', async () => {
|
||||
const mockViewModel = { totalRaces: 5 } as { totalRaces: number };
|
||||
service.getTotalRaces.mockResolvedValue({ viewModel: mockViewModel } as unknown as ReturnType<RaceService['getTotalRaces']>);
|
||||
|
||||
const result = await controller.getTotalRaces();
|
||||
|
||||
expect(service.getTotalRaces).toHaveBeenCalled();
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(result).toEqual(mockViewModel);
|
||||
});
|
||||
});
|
||||
|
||||
// Add more tests as needed
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Get, HttpCode, HttpStatus, InternalServerErrorException, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { RaceService } from './RaceService';
|
||||
import { AllRacesPageDTO } from './dtos/AllRacesPageDTO';
|
||||
@@ -27,28 +27,32 @@ export class RaceController {
|
||||
@ApiOperation({ summary: 'Get all races' })
|
||||
@ApiResponse({ status: 200, description: 'List of all races', type: AllRacesPageDTO })
|
||||
async getAllRaces(): Promise<AllRacesPageDTO> {
|
||||
return this.raceService.getAllRaces();
|
||||
const presenter = await this.raceService.getAllRaces();
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Get('total-races')
|
||||
@ApiOperation({ summary: 'Get the total number of races' })
|
||||
@ApiResponse({ status: 200, description: 'Total number of races', type: RaceStatsDTO })
|
||||
async getTotalRaces(): Promise<RaceStatsDTO> {
|
||||
return this.raceService.getTotalRaces();
|
||||
const presenter = await this.raceService.getTotalRaces();
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Get('page-data')
|
||||
@ApiOperation({ summary: 'Get races page data' })
|
||||
@ApiResponse({ status: 200, description: 'Races page data', type: RacesPageDataDTO })
|
||||
async getRacesPageData(): Promise<RacesPageDataDTO> {
|
||||
return this.raceService.getRacesPageData();
|
||||
const presenter = await this.raceService.getRacesPageData();
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Get('all/page-data')
|
||||
@ApiOperation({ summary: 'Get all races page data' })
|
||||
@ApiResponse({ status: 200, description: 'All races page data', type: AllRacesPageDTO })
|
||||
async getAllRacesPageData(): Promise<AllRacesPageDTO> {
|
||||
return this.raceService.getAllRacesPageData();
|
||||
const presenter = await this.raceService.getAllRacesPageData();
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Get(':raceId')
|
||||
@@ -60,7 +64,8 @@ export class RaceController {
|
||||
@Param('raceId') raceId: string,
|
||||
@Query('driverId') driverId: string,
|
||||
): Promise<RaceDetailDTO> {
|
||||
return this.raceService.getRaceDetail({ raceId, driverId });
|
||||
const presenter = await this.raceService.getRaceDetail({ raceId, driverId });
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Get(':raceId/results')
|
||||
@@ -68,7 +73,8 @@ export class RaceController {
|
||||
@ApiParam({ name: 'raceId', description: 'Race ID' })
|
||||
@ApiResponse({ status: 200, description: 'Race results detail', type: RaceResultsDetailDTO })
|
||||
async getRaceResultsDetail(@Param('raceId') raceId: string): Promise<RaceResultsDetailDTO> {
|
||||
return this.raceService.getRaceResultsDetail(raceId);
|
||||
const presenter = await this.raceService.getRaceResultsDetail(raceId);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Get(':raceId/sof')
|
||||
@@ -76,7 +82,8 @@ export class RaceController {
|
||||
@ApiParam({ name: 'raceId', description: 'Race ID' })
|
||||
@ApiResponse({ status: 200, description: 'Race with SOF', type: RaceWithSOFDTO })
|
||||
async getRaceWithSOF(@Param('raceId') raceId: string): Promise<RaceWithSOFDTO> {
|
||||
return this.raceService.getRaceWithSOF(raceId);
|
||||
const presenter = await this.raceService.getRaceWithSOF(raceId);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Get(':raceId/protests')
|
||||
@@ -84,7 +91,8 @@ export class RaceController {
|
||||
@ApiParam({ name: 'raceId', description: 'Race ID' })
|
||||
@ApiResponse({ status: 200, description: 'Race protests', type: RaceProtestsDTO })
|
||||
async getRaceProtests(@Param('raceId') raceId: string): Promise<RaceProtestsDTO> {
|
||||
return this.raceService.getRaceProtests(raceId);
|
||||
const presenter = await this.raceService.getRaceProtests(raceId);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Get(':raceId/penalties')
|
||||
@@ -92,7 +100,8 @@ export class RaceController {
|
||||
@ApiParam({ name: 'raceId', description: 'Race ID' })
|
||||
@ApiResponse({ status: 200, description: 'Race penalties', type: RacePenaltiesDTO })
|
||||
async getRacePenalties(@Param('raceId') raceId: string): Promise<RacePenaltiesDTO> {
|
||||
return this.raceService.getRacePenalties(raceId);
|
||||
const presenter = await this.raceService.getRacePenalties(raceId);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Post(':raceId/register')
|
||||
@@ -104,7 +113,12 @@ export class RaceController {
|
||||
@Param('raceId') raceId: string,
|
||||
@Body() body: Omit<RegisterForRaceParamsDTO, 'raceId'>,
|
||||
): Promise<void> {
|
||||
return this.raceService.registerForRace({ raceId, ...body });
|
||||
const presenter = await this.raceService.registerForRace({ raceId, ...body });
|
||||
const viewModel = presenter.viewModel;
|
||||
|
||||
if (!viewModel.success) {
|
||||
throw new InternalServerErrorException(viewModel.message ?? 'Failed to register for race');
|
||||
}
|
||||
}
|
||||
|
||||
@Post(':raceId/withdraw')
|
||||
@@ -116,7 +130,12 @@ export class RaceController {
|
||||
@Param('raceId') raceId: string,
|
||||
@Body() body: Omit<WithdrawFromRaceParamsDTO, 'raceId'>,
|
||||
): Promise<void> {
|
||||
return this.raceService.withdrawFromRace({ raceId, ...body });
|
||||
const presenter = await this.raceService.withdrawFromRace({ raceId, ...body });
|
||||
const viewModel = presenter.viewModel;
|
||||
|
||||
if (!viewModel.success) {
|
||||
throw new InternalServerErrorException(viewModel.message ?? 'Failed to withdraw from race');
|
||||
}
|
||||
}
|
||||
|
||||
@Post(':raceId/cancel')
|
||||
@@ -125,7 +144,12 @@ export class RaceController {
|
||||
@ApiParam({ name: 'raceId', description: 'Race ID' })
|
||||
@ApiResponse({ status: 200, description: 'Successfully cancelled race' })
|
||||
async cancelRace(@Param('raceId') raceId: string): Promise<void> {
|
||||
return this.raceService.cancelRace({ raceId });
|
||||
const presenter = await this.raceService.cancelRace({ raceId });
|
||||
const viewModel = presenter.viewModel;
|
||||
|
||||
if (!viewModel.success) {
|
||||
throw new InternalServerErrorException(viewModel.message ?? 'Failed to cancel race');
|
||||
}
|
||||
}
|
||||
|
||||
@Post(':raceId/complete')
|
||||
@@ -134,7 +158,12 @@ export class RaceController {
|
||||
@ApiParam({ name: 'raceId', description: 'Race ID' })
|
||||
@ApiResponse({ status: 200, description: 'Successfully completed race' })
|
||||
async completeRace(@Param('raceId') raceId: string): Promise<void> {
|
||||
return this.raceService.completeRace({ raceId });
|
||||
const presenter = await this.raceService.completeRace({ raceId });
|
||||
const viewModel = presenter.viewModel;
|
||||
|
||||
if (!viewModel.success) {
|
||||
throw new InternalServerErrorException(viewModel.message ?? 'Failed to complete race');
|
||||
}
|
||||
}
|
||||
|
||||
@Post(':raceId/reopen')
|
||||
@@ -143,7 +172,12 @@ export class RaceController {
|
||||
@ApiParam({ name: 'raceId', description: 'Race ID' })
|
||||
@ApiResponse({ status: 200, description: 'Successfully re-opened race' })
|
||||
async reopenRace(@Param('raceId') raceId: string): Promise<void> {
|
||||
return this.raceService.reopenRace({ raceId });
|
||||
const presenter = await this.raceService.reopenRace({ raceId });
|
||||
const viewModel = presenter.viewModel;
|
||||
|
||||
if (!viewModel.success) {
|
||||
throw new InternalServerErrorException(viewModel.message ?? 'Failed to re-open race');
|
||||
}
|
||||
}
|
||||
|
||||
@Post(':raceId/import-results')
|
||||
@@ -155,7 +189,8 @@ export class RaceController {
|
||||
@Param('raceId') raceId: string,
|
||||
@Body() body: Omit<ImportRaceResultsDTO, 'raceId'>,
|
||||
): Promise<ImportRaceResultsSummaryDTO> {
|
||||
return this.raceService.importRaceResults({ raceId, ...body });
|
||||
const presenter = await this.raceService.importRaceResults({ raceId, ...body });
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Post('protests/file')
|
||||
@@ -163,7 +198,12 @@ export class RaceController {
|
||||
@ApiOperation({ summary: 'File a protest' })
|
||||
@ApiResponse({ status: 200, description: 'Protest filed successfully' })
|
||||
async fileProtest(@Body() body: FileProtestCommandDTO): Promise<void> {
|
||||
return this.raceService.fileProtest(body);
|
||||
const presenter = await this.raceService.fileProtest(body);
|
||||
const viewModel = presenter.viewModel;
|
||||
|
||||
if (!viewModel.success) {
|
||||
throw new InternalServerErrorException(viewModel.message ?? 'Failed to file protest');
|
||||
}
|
||||
}
|
||||
|
||||
@Post('penalties/quick')
|
||||
@@ -171,7 +211,12 @@ export class RaceController {
|
||||
@ApiOperation({ summary: 'Apply a quick penalty' })
|
||||
@ApiResponse({ status: 200, description: 'Penalty applied successfully' })
|
||||
async applyQuickPenalty(@Body() body: QuickPenaltyCommandDTO): Promise<void> {
|
||||
return this.raceService.applyQuickPenalty(body);
|
||||
const presenter = await this.raceService.applyQuickPenalty(body);
|
||||
const viewModel = presenter.viewModel;
|
||||
|
||||
if (!viewModel.success) {
|
||||
throw new InternalServerErrorException(viewModel.message ?? 'Failed to apply quick penalty');
|
||||
}
|
||||
}
|
||||
|
||||
@Post('penalties/apply')
|
||||
@@ -179,7 +224,12 @@ export class RaceController {
|
||||
@ApiOperation({ summary: 'Apply a penalty' })
|
||||
@ApiResponse({ status: 200, description: 'Penalty applied successfully' })
|
||||
async applyPenalty(@Body() body: ApplyPenaltyCommandDTO): Promise<void> {
|
||||
return this.raceService.applyPenalty(body);
|
||||
const presenter = await this.raceService.applyPenalty(body);
|
||||
const viewModel = presenter.viewModel;
|
||||
|
||||
if (!viewModel.success) {
|
||||
throw new InternalServerErrorException(viewModel.message ?? 'Failed to apply penalty');
|
||||
}
|
||||
}
|
||||
|
||||
@Post('protests/defense/request')
|
||||
@@ -187,6 +237,11 @@ export class RaceController {
|
||||
@ApiOperation({ summary: 'Request protest defense' })
|
||||
@ApiResponse({ status: 200, description: 'Defense requested successfully' })
|
||||
async requestProtestDefense(@Body() body: RequestProtestDefenseCommandDTO): Promise<void> {
|
||||
return this.raceService.requestProtestDefense(body);
|
||||
const presenter = await this.raceService.requestProtestDefense(body);
|
||||
const viewModel = presenter.viewModel;
|
||||
|
||||
if (!viewModel.success) {
|
||||
throw new InternalServerErrorException(viewModel.message ?? 'Failed to request protest defense');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import type { ILeagueMembershipRepository } from '@core/racing/domain/repositori
|
||||
import type { IPenaltyRepository } from '@core/racing/domain/repositories/IPenaltyRepository';
|
||||
import type { IProtestRepository } from '@core/racing/domain/repositories/IProtestRepository';
|
||||
import type { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider';
|
||||
import type { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
|
||||
|
||||
// Import concrete in-memory implementations
|
||||
import { InMemoryRaceRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRepository';
|
||||
|
||||
168
apps/api/src/domain/race/RaceService.test.ts
Normal file
168
apps/api/src/domain/race/RaceService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ConflictException, Inject, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||
import type { AllRacesPageViewModel } from '@core/racing/application/presenters/IGetAllRacesPresenter';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { RaceDetailOutputPort } from '@core/racing/application/ports/output/RaceDetailOutputPort';
|
||||
import type { RacesPageOutputPort } from '@core/racing/application/ports/output/RacesPageOutputPort';
|
||||
import type { RaceResultsDetailOutputPort } from '@core/racing/application/ports/output/RaceResultsDetailOutputPort';
|
||||
@@ -13,17 +12,11 @@ import { RegisterForRaceParamsDTO } from './dtos/RegisterForRaceParamsDTO';
|
||||
import { WithdrawFromRaceParamsDTO } from './dtos/WithdrawFromRaceParamsDTO';
|
||||
import { RaceActionParamsDTO } from './dtos/RaceActionParamsDTO';
|
||||
import { ImportRaceResultsDTO } from './dtos/ImportRaceResultsDTO';
|
||||
import { AllRacesPageDTO } from './dtos/AllRacesPageDTO';
|
||||
import { RaceStatsDTO } from './dtos/RaceStatsDTO';
|
||||
import { RacePenaltiesDTO } from './dtos/RacePenaltiesDTO';
|
||||
import { RaceProtestsDTO } from './dtos/RaceProtestsDTO';
|
||||
import { RaceResultsDetailDTO } from './dtos/RaceResultsDetailDTO';
|
||||
|
||||
// Core imports
|
||||
import type { Logger } from '@core/shared/application/Logger';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider';
|
||||
import { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
|
||||
import type { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
|
||||
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
|
||||
|
||||
// Use cases
|
||||
@@ -41,7 +34,6 @@ import { RegisterForRaceUseCase } from '@core/racing/application/use-cases/Regis
|
||||
import { WithdrawFromRaceUseCase } from '@core/racing/application/use-cases/WithdrawFromRaceUseCase';
|
||||
import { CancelRaceUseCase } from '@core/racing/application/use-cases/CancelRaceUseCase';
|
||||
import { CompleteRaceUseCase } from '@core/racing/application/use-cases/CompleteRaceUseCase';
|
||||
import { ImportRaceResultsUseCase } from '@core/racing/application/use-cases/ImportRaceResultsUseCase';
|
||||
import { FileProtestUseCase } from '@core/racing/application/use-cases/FileProtestUseCase';
|
||||
import { QuickPenaltyUseCase } from '@core/racing/application/use-cases/QuickPenaltyUseCase';
|
||||
import { ApplyPenaltyUseCase } from '@core/racing/application/use-cases/ApplyPenaltyUseCase';
|
||||
@@ -53,6 +45,14 @@ import { ReopenRaceUseCase } from '@core/racing/application/use-cases/ReopenRace
|
||||
import { GetAllRacesPresenter } from './presenters/GetAllRacesPresenter';
|
||||
import { GetTotalRacesPresenter } from './presenters/GetTotalRacesPresenter';
|
||||
import { ImportRaceResultsApiPresenter } from './presenters/ImportRaceResultsApiPresenter';
|
||||
import { RaceDetailPresenter } from './presenters/RaceDetailPresenter';
|
||||
import { RacesPageDataPresenter } from './presenters/RacesPageDataPresenter';
|
||||
import { AllRacesPageDataPresenter } from './presenters/AllRacesPageDataPresenter';
|
||||
import { RaceResultsDetailPresenter } from './presenters/RaceResultsDetailPresenter';
|
||||
import { RaceWithSOFPresenter } from './presenters/RaceWithSOFPresenter';
|
||||
import { RaceProtestsPresenter } from './presenters/RaceProtestsPresenter';
|
||||
import { RacePenaltiesPresenter } from './presenters/RacePenaltiesPresenter';
|
||||
import { CommandResultPresenter } from './presenters/CommandResultPresenter';
|
||||
|
||||
// Command DTOs
|
||||
import { FileProtestCommandDTO } from './dtos/FileProtestCommandDTO';
|
||||
@@ -93,15 +93,21 @@ export class RaceService {
|
||||
@Inject(IMAGE_SERVICE_TOKEN) private readonly imageService: IImageServicePort,
|
||||
) {}
|
||||
|
||||
async getAllRaces(): Promise<AllRacesPageViewModel> {
|
||||
async getAllRaces(): Promise<GetAllRacesPresenter> {
|
||||
this.logger.debug('[RaceService] Fetching all races.');
|
||||
|
||||
const result = await this.getAllRacesUseCase.execute();
|
||||
|
||||
if (result.isErr()) {
|
||||
throw new Error('Failed to get all races');
|
||||
}
|
||||
|
||||
const presenter = new GetAllRacesPresenter();
|
||||
await this.getAllRacesUseCase.execute({}, presenter);
|
||||
return presenter.getViewModel()!;
|
||||
await presenter.present(result.unwrap());
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getTotalRaces(): Promise<RaceStatsDTO> {
|
||||
async getTotalRaces(): Promise<GetTotalRacesPresenter> {
|
||||
this.logger.debug('[RaceService] Fetching total races count.');
|
||||
const result = await this.getTotalRacesUseCase.execute();
|
||||
if (result.isErr()) {
|
||||
@@ -109,10 +115,10 @@ export class RaceService {
|
||||
}
|
||||
const presenter = new GetTotalRacesPresenter();
|
||||
presenter.present(result.unwrap());
|
||||
return presenter.getViewModel()!;
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async importRaceResults(input: ImportRaceResultsDTO): Promise<ImportRaceResultsSummaryDTO> {
|
||||
async importRaceResults(input: ImportRaceResultsDTO): Promise<ImportRaceResultsApiPresenter> {
|
||||
this.logger.debug('Importing race results:', input);
|
||||
const result = await this.importRaceResultsApiUseCase.execute({ raceId: input.raceId, resultsFileContent: input.resultsFileContent });
|
||||
if (result.isErr()) {
|
||||
@@ -120,10 +126,10 @@ export class RaceService {
|
||||
}
|
||||
const presenter = new ImportRaceResultsApiPresenter();
|
||||
presenter.present(result.unwrap());
|
||||
return presenter.getViewModel()!;
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getRaceDetail(params: GetRaceDetailParamsDTO): Promise<RaceDetailDTO> {
|
||||
async getRaceDetail(params: GetRaceDetailParamsDTO): Promise<RaceDetailPresenter> {
|
||||
this.logger.debug('[RaceService] Fetching race detail:', params);
|
||||
|
||||
const result = await this.getRaceDetailUseCase.execute(params);
|
||||
@@ -132,79 +138,12 @@ export class RaceService {
|
||||
throw new Error('Failed to get race detail');
|
||||
}
|
||||
|
||||
const outputPort = result.value as RaceDetailOutputPort;
|
||||
|
||||
// Map to DTO
|
||||
const raceDTO = outputPort.race
|
||||
? {
|
||||
id: outputPort.race.id,
|
||||
leagueId: outputPort.race.leagueId,
|
||||
track: outputPort.race.track,
|
||||
car: outputPort.race.car,
|
||||
scheduledAt: outputPort.race.scheduledAt.toISOString(),
|
||||
sessionType: outputPort.race.sessionType,
|
||||
status: outputPort.race.status,
|
||||
strengthOfField: outputPort.race.strengthOfField ?? null,
|
||||
registeredCount: outputPort.race.registeredCount ?? undefined,
|
||||
maxParticipants: outputPort.race.maxParticipants ?? undefined,
|
||||
}
|
||||
: null;
|
||||
|
||||
const leagueDTO = outputPort.league
|
||||
? {
|
||||
id: outputPort.league.id.toString(),
|
||||
name: outputPort.league.name.toString(),
|
||||
description: outputPort.league.description.toString(),
|
||||
settings: {
|
||||
maxDrivers: outputPort.league.settings.maxDrivers ?? undefined,
|
||||
qualifyingFormat: outputPort.league.settings.qualifyingFormat ?? undefined,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
|
||||
const entryListDTO = await Promise.all(
|
||||
outputPort.drivers.map(async driver => {
|
||||
const ratingResult = await this.driverRatingProvider.getDriverRating({ driverId: driver.id });
|
||||
const avatarResult = await this.imageService.getDriverAvatar({ driverId: driver.id });
|
||||
return {
|
||||
id: driver.id,
|
||||
name: driver.name.toString(),
|
||||
country: driver.country.toString(),
|
||||
avatarUrl: avatarResult.avatarUrl,
|
||||
rating: ratingResult.rating,
|
||||
isCurrentUser: driver.id === params.driverId,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const registrationDTO = {
|
||||
isUserRegistered: outputPort.isUserRegistered,
|
||||
canRegister: outputPort.canRegister,
|
||||
};
|
||||
|
||||
const userResultDTO = outputPort.userResult
|
||||
? {
|
||||
position: outputPort.userResult.position.toNumber(),
|
||||
startPosition: outputPort.userResult.startPosition.toNumber(),
|
||||
incidents: outputPort.userResult.incidents.toNumber(),
|
||||
fastestLap: outputPort.userResult.fastestLap.toNumber(),
|
||||
positionChange: outputPort.userResult.getPositionChange(),
|
||||
isPodium: outputPort.userResult.isPodium(),
|
||||
isClean: outputPort.userResult.isClean(),
|
||||
ratingChange: this.calculateRatingChange(outputPort.userResult.position.toNumber()),
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
race: raceDTO,
|
||||
league: leagueDTO,
|
||||
entryList: entryListDTO,
|
||||
registration: registrationDTO,
|
||||
userResult: userResultDTO,
|
||||
};
|
||||
const presenter = new RaceDetailPresenter(this.driverRatingProvider, this.imageService);
|
||||
await presenter.present(result.value as RaceDetailOutputPort, params);
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getRacesPageData(): Promise<RacesPageDataDTO> {
|
||||
async getRacesPageData(): Promise<RacesPageDataPresenter> {
|
||||
this.logger.debug('[RaceService] Fetching races page data.');
|
||||
|
||||
const result = await this.getRacesPageDataUseCase.execute();
|
||||
@@ -213,33 +152,12 @@ export class RaceService {
|
||||
throw new Error('Failed to get races page data');
|
||||
}
|
||||
|
||||
const outputPort = result.value as RacesPageOutputPort;
|
||||
|
||||
// Fetch leagues for league names
|
||||
const allLeagues = await this.leagueRepository.findAll();
|
||||
const leagueMap = new Map(allLeagues.map(l => [l.id, l.name]));
|
||||
|
||||
// Map to DTO
|
||||
const racesDTO = outputPort.races.map(race => ({
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
scheduledAt: race.scheduledAt.toISOString(),
|
||||
status: race.status,
|
||||
leagueId: race.leagueId,
|
||||
leagueName: leagueMap.get(race.leagueId) ?? 'Unknown League',
|
||||
strengthOfField: race.strengthOfField,
|
||||
isUpcoming: race.scheduledAt > new Date(),
|
||||
isLive: race.status === 'running',
|
||||
isPast: race.scheduledAt < new Date() && race.status === 'completed',
|
||||
}));
|
||||
|
||||
return {
|
||||
races: racesDTO,
|
||||
};
|
||||
const presenter = new RacesPageDataPresenter(this.leagueRepository);
|
||||
await presenter.present(result.value as RacesPageOutputPort);
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getAllRacesPageData(): Promise<AllRacesPageDTO> {
|
||||
async getAllRacesPageData(): Promise<AllRacesPageDataPresenter> {
|
||||
this.logger.debug('[RaceService] Fetching all races page data.');
|
||||
|
||||
const result = await this.getAllRacesPageDataUseCase.execute();
|
||||
@@ -248,10 +166,12 @@ export class RaceService {
|
||||
throw new Error('Failed to get all races page data');
|
||||
}
|
||||
|
||||
return result.value as AllRacesPageDTO;
|
||||
const presenter = new AllRacesPageDataPresenter();
|
||||
presenter.present(result.value);
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getRaceResultsDetail(raceId: string): Promise<RaceResultsDetailDTO> {
|
||||
async getRaceResultsDetail(raceId: string): Promise<RaceResultsDetailPresenter> {
|
||||
this.logger.debug('[RaceService] Fetching race results detail:', { raceId });
|
||||
|
||||
const result = await this.getRaceResultsDetailUseCase.execute({ raceId });
|
||||
@@ -260,43 +180,12 @@ export class RaceService {
|
||||
throw new Error('Failed to get race results detail');
|
||||
}
|
||||
|
||||
const outputPort = result.value as RaceResultsDetailOutputPort;
|
||||
|
||||
// Create a map of driverId to driver for easy lookup
|
||||
const driverMap = new Map(outputPort.drivers.map(driver => [driver.id, driver]));
|
||||
|
||||
const resultsDTO = await Promise.all(
|
||||
outputPort.results.map(async singleResult => {
|
||||
const driver = driverMap.get(singleResult.driverId.toString());
|
||||
if (!driver) {
|
||||
throw new Error(`Driver not found for result: ${singleResult.driverId}`);
|
||||
}
|
||||
|
||||
const avatarResult = await this.imageService.getDriverAvatar({ driverId: driver.id });
|
||||
|
||||
return {
|
||||
driverId: singleResult.driverId.toString(),
|
||||
driverName: driver.name.toString(),
|
||||
avatarUrl: avatarResult.avatarUrl,
|
||||
position: singleResult.position.toNumber(),
|
||||
startPosition: singleResult.startPosition.toNumber(),
|
||||
incidents: singleResult.incidents.toNumber(),
|
||||
fastestLap: singleResult.fastestLap.toNumber(),
|
||||
positionChange: singleResult.getPositionChange(),
|
||||
isPodium: singleResult.isPodium(),
|
||||
isClean: singleResult.isClean(),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
raceId: outputPort.race.id,
|
||||
track: outputPort.race.track,
|
||||
results: resultsDTO,
|
||||
};
|
||||
const presenter = new RaceResultsDetailPresenter(this.imageService);
|
||||
await presenter.present(result.value as RaceResultsDetailOutputPort);
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getRaceWithSOF(raceId: string): Promise<RaceWithSOFDTO> {
|
||||
async getRaceWithSOF(raceId: string): Promise<RaceWithSOFPresenter> {
|
||||
this.logger.debug('[RaceService] Fetching race with SOF:', { raceId });
|
||||
|
||||
const result = await this.getRaceWithSOFUseCase.execute({ raceId });
|
||||
@@ -305,17 +194,12 @@ export class RaceService {
|
||||
throw new Error('Failed to get race with SOF');
|
||||
}
|
||||
|
||||
const outputPort = result.value as RaceWithSOFOutputPort;
|
||||
|
||||
// Map to DTO
|
||||
return {
|
||||
id: outputPort.id,
|
||||
track: outputPort.track,
|
||||
strengthOfField: outputPort.strengthOfField,
|
||||
};
|
||||
const presenter = new RaceWithSOFPresenter();
|
||||
presenter.present(result.value as RaceWithSOFOutputPort);
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getRaceProtests(raceId: string): Promise<RaceProtestsDTO> {
|
||||
async getRaceProtests(raceId: string): Promise<RaceProtestsPresenter> {
|
||||
this.logger.debug('[RaceService] Fetching race protests:', { raceId });
|
||||
|
||||
const result = await this.getRaceProtestsUseCase.execute({ raceId });
|
||||
@@ -324,32 +208,12 @@ export class RaceService {
|
||||
throw new Error('Failed to get race protests');
|
||||
}
|
||||
|
||||
const outputPort = result.value as RaceProtestsOutputPort;
|
||||
|
||||
const protestsDTO = outputPort.protests.map(protest => ({
|
||||
id: protest.id,
|
||||
protestingDriverId: protest.protestingDriverId,
|
||||
accusedDriverId: protest.accusedDriverId,
|
||||
incident: {
|
||||
lap: protest.incident.lap,
|
||||
description: protest.incident.description,
|
||||
},
|
||||
status: protest.status,
|
||||
filedAt: protest.filedAt.toISOString(),
|
||||
}));
|
||||
|
||||
const driverMap: Record<string, string> = {};
|
||||
outputPort.drivers.forEach(driver => {
|
||||
driverMap[driver.id] = driver.name.toString();
|
||||
});
|
||||
|
||||
return {
|
||||
protests: protestsDTO,
|
||||
driverMap,
|
||||
};
|
||||
const presenter = new RaceProtestsPresenter();
|
||||
presenter.present(result.value as RaceProtestsOutputPort);
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getRacePenalties(raceId: string): Promise<RacePenaltiesDTO> {
|
||||
async getRacePenalties(raceId: string): Promise<RacePenaltiesPresenter> {
|
||||
this.logger.debug('[RaceService] Fetching race penalties:', { raceId });
|
||||
|
||||
const result = await this.getRacePenaltiesUseCase.execute({ raceId });
|
||||
@@ -358,148 +222,175 @@ export class RaceService {
|
||||
throw new Error('Failed to get race penalties');
|
||||
}
|
||||
|
||||
const outputPort = result.value as RacePenaltiesOutputPort;
|
||||
|
||||
const penaltiesDTO = outputPort.penalties.map(penalty => ({
|
||||
id: penalty.id,
|
||||
driverId: penalty.driverId,
|
||||
type: penalty.type,
|
||||
value: penalty.value ?? 0,
|
||||
reason: penalty.reason,
|
||||
issuedBy: penalty.issuedBy,
|
||||
issuedAt: penalty.issuedAt.toISOString(),
|
||||
notes: penalty.notes,
|
||||
}));
|
||||
|
||||
const driverMap: Record<string, string> = {};
|
||||
outputPort.drivers.forEach(driver => {
|
||||
driverMap[driver.id] = driver.name.toString();
|
||||
});
|
||||
|
||||
return {
|
||||
penalties: penaltiesDTO,
|
||||
driverMap,
|
||||
};
|
||||
const presenter = new RacePenaltiesPresenter();
|
||||
presenter.present(result.value as RacePenaltiesOutputPort);
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async registerForRace(params: RegisterForRaceParamsDTO): Promise<void> {
|
||||
async registerForRace(params: RegisterForRaceParamsDTO): Promise<CommandResultPresenter> {
|
||||
this.logger.debug('[RaceService] Registering for race:', params);
|
||||
|
||||
const result = await this.registerForRaceUseCase.execute(params);
|
||||
|
||||
const presenter = new CommandResultPresenter();
|
||||
if (result.isErr()) {
|
||||
throw new Error('Failed to register for race');
|
||||
const error = result.unwrapErr();
|
||||
presenter.presentFailure(error.code ?? 'FAILED_TO_REGISTER_FOR_RACE', 'Failed to register for race');
|
||||
return presenter;
|
||||
}
|
||||
|
||||
presenter.presentSuccess();
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async withdrawFromRace(params: WithdrawFromRaceParamsDTO): Promise<void> {
|
||||
async withdrawFromRace(params: WithdrawFromRaceParamsDTO): Promise<CommandResultPresenter> {
|
||||
this.logger.debug('[RaceService] Withdrawing from race:', params);
|
||||
|
||||
const result = await this.withdrawFromRaceUseCase.execute(params);
|
||||
|
||||
const presenter = new CommandResultPresenter();
|
||||
if (result.isErr()) {
|
||||
throw new Error('Failed to withdraw from race');
|
||||
const error = result.unwrapErr();
|
||||
presenter.presentFailure(error.code ?? 'FAILED_TO_WITHDRAW_FROM_RACE', 'Failed to withdraw from race');
|
||||
return presenter;
|
||||
}
|
||||
|
||||
presenter.presentSuccess();
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async cancelRace(params: RaceActionParamsDTO): Promise<void> {
|
||||
async cancelRace(params: RaceActionParamsDTO): Promise<CommandResultPresenter> {
|
||||
this.logger.debug('[RaceService] Cancelling race:', params);
|
||||
|
||||
const result = await this.cancelRaceUseCase.execute({ raceId: params.raceId });
|
||||
|
||||
const presenter = new CommandResultPresenter();
|
||||
if (result.isErr()) {
|
||||
throw new Error('Failed to cancel race');
|
||||
const error = result.unwrapErr();
|
||||
presenter.presentFailure(error.code ?? 'FAILED_TO_CANCEL_RACE', 'Failed to cancel race');
|
||||
return presenter;
|
||||
}
|
||||
|
||||
presenter.presentSuccess();
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async completeRace(params: RaceActionParamsDTO): Promise<void> {
|
||||
async completeRace(params: RaceActionParamsDTO): Promise<CommandResultPresenter> {
|
||||
this.logger.debug('[RaceService] Completing race:', params);
|
||||
|
||||
const result = await this.completeRaceUseCase.execute({ raceId: params.raceId });
|
||||
|
||||
const presenter = new CommandResultPresenter();
|
||||
if (result.isErr()) {
|
||||
throw new Error('Failed to complete race');
|
||||
const error = result.unwrapErr();
|
||||
presenter.presentFailure(error.code ?? 'FAILED_TO_COMPLETE_RACE', 'Failed to complete race');
|
||||
return presenter;
|
||||
}
|
||||
|
||||
presenter.presentSuccess();
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async reopenRace(params: RaceActionParamsDTO): Promise<void> {
|
||||
async reopenRace(params: RaceActionParamsDTO): Promise<CommandResultPresenter> {
|
||||
this.logger.debug('[RaceService] Re-opening race:', params);
|
||||
|
||||
const result = await this.reopenRaceUseCase.execute({ raceId: params.raceId });
|
||||
|
||||
const presenter = new CommandResultPresenter();
|
||||
if (result.isErr()) {
|
||||
const errorCode = result.unwrapErr().code;
|
||||
|
||||
if (errorCode === 'RACE_NOT_FOUND') {
|
||||
throw new NotFoundException('Race not found');
|
||||
}
|
||||
|
||||
if (errorCode === 'CANNOT_REOPEN_RUNNING_RACE') {
|
||||
throw new ConflictException('Cannot re-open a running race');
|
||||
}
|
||||
|
||||
if (errorCode === 'RACE_ALREADY_SCHEDULED') {
|
||||
this.logger.debug('[RaceService] Race is already scheduled, treating reopen as success.');
|
||||
return;
|
||||
presenter.presentSuccess('Race already scheduled');
|
||||
return presenter;
|
||||
}
|
||||
|
||||
throw new InternalServerErrorException(errorCode ?? 'UNEXPECTED_ERROR');
|
||||
presenter.presentFailure(errorCode ?? 'UNEXPECTED_ERROR', 'Unexpected error while reopening race');
|
||||
return presenter;
|
||||
}
|
||||
|
||||
presenter.presentSuccess();
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async fileProtest(command: FileProtestCommandDTO): Promise<void> {
|
||||
async fileProtest(command: FileProtestCommandDTO): Promise<CommandResultPresenter> {
|
||||
this.logger.debug('[RaceService] Filing protest:', command);
|
||||
|
||||
const result = await this.fileProtestUseCase.execute(command);
|
||||
|
||||
const presenter = new CommandResultPresenter();
|
||||
if (result.isErr()) {
|
||||
throw new Error('Failed to file protest');
|
||||
const error = result.unwrapErr();
|
||||
presenter.presentFailure(error.code ?? 'FAILED_TO_FILE_PROTEST', 'Failed to file protest');
|
||||
return presenter;
|
||||
}
|
||||
|
||||
presenter.presentSuccess();
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async applyQuickPenalty(command: QuickPenaltyCommandDTO): Promise<void> {
|
||||
async applyQuickPenalty(command: QuickPenaltyCommandDTO): Promise<CommandResultPresenter> {
|
||||
this.logger.debug('[RaceService] Applying quick penalty:', command);
|
||||
|
||||
const result = await this.quickPenaltyUseCase.execute(command);
|
||||
|
||||
const presenter = new CommandResultPresenter();
|
||||
if (result.isErr()) {
|
||||
throw new Error('Failed to apply quick penalty');
|
||||
const error = result.unwrapErr();
|
||||
presenter.presentFailure(error.code ?? 'FAILED_TO_APPLY_QUICK_PENALTY', 'Failed to apply quick penalty');
|
||||
return presenter;
|
||||
}
|
||||
|
||||
presenter.presentSuccess();
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async applyPenalty(command: ApplyPenaltyCommandDTO): Promise<void> {
|
||||
async applyPenalty(command: ApplyPenaltyCommandDTO): Promise<CommandResultPresenter> {
|
||||
this.logger.debug('[RaceService] Applying penalty:', command);
|
||||
|
||||
const result = await this.applyPenaltyUseCase.execute(command);
|
||||
|
||||
const presenter = new CommandResultPresenter();
|
||||
if (result.isErr()) {
|
||||
throw new Error('Failed to apply penalty');
|
||||
const error = result.unwrapErr();
|
||||
presenter.presentFailure(error.code ?? 'FAILED_TO_APPLY_PENALTY', 'Failed to apply penalty');
|
||||
return presenter;
|
||||
}
|
||||
|
||||
presenter.presentSuccess();
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async requestProtestDefense(command: RequestProtestDefenseCommandDTO): Promise<void> {
|
||||
async requestProtestDefense(command: RequestProtestDefenseCommandDTO): Promise<CommandResultPresenter> {
|
||||
this.logger.debug('[RaceService] Requesting protest defense:', command);
|
||||
|
||||
const result = await this.requestProtestDefenseUseCase.execute(command);
|
||||
|
||||
const presenter = new CommandResultPresenter();
|
||||
if (result.isErr()) {
|
||||
throw new Error('Failed to request protest defense');
|
||||
const error = result.unwrapErr();
|
||||
presenter.presentFailure(error.code ?? 'FAILED_TO_REQUEST_PROTEST_DEFENSE', 'Failed to request protest defense');
|
||||
return presenter;
|
||||
}
|
||||
|
||||
presenter.presentSuccess();
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async reviewProtest(command: ReviewProtestCommandDTO): Promise<void> {
|
||||
async reviewProtest(command: ReviewProtestCommandDTO): Promise<CommandResultPresenter> {
|
||||
this.logger.debug('[RaceService] Reviewing protest:', command);
|
||||
|
||||
const result = await this.reviewProtestUseCase.execute(command);
|
||||
|
||||
const presenter = new CommandResultPresenter();
|
||||
if (result.isErr()) {
|
||||
throw new Error('Failed to review protest');
|
||||
const error = result.unwrapErr();
|
||||
presenter.presentFailure(error.code ?? 'FAILED_TO_REVIEW_PROTEST', 'Failed to review protest');
|
||||
return presenter;
|
||||
}
|
||||
}
|
||||
|
||||
private calculateRatingChange(position: number): number {
|
||||
const baseChange = position <= 3 ? 25 : position <= 10 ? 10 : -5;
|
||||
const positionBonus = Math.max(0, (20 - position) * 2);
|
||||
return baseChange + positionBonus;
|
||||
presenter.presentSuccess();
|
||||
return presenter;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNumber, IsOptional } from 'class-validator';
|
||||
import { IsNumber } from 'class-validator';
|
||||
import { DashboardDriverSummaryDTO } from './DashboardDriverSummaryDTO';
|
||||
import { DashboardRaceSummaryDTO } from './DashboardRaceSummaryDTO';
|
||||
import { DashboardRecentResultDTO } from './DashboardRecentResultDTO';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, IsNotEmpty, IsOptional, IsNumber, IsUrl } from 'class-validator';
|
||||
import { IsString, IsNotEmpty, IsOptional, IsUrl } from 'class-validator';
|
||||
|
||||
export class FileProtestCommandDTO {
|
||||
@ApiProperty()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, IsBoolean, IsNumber } from 'class-validator';
|
||||
import { IsString, IsBoolean } from 'class-validator';
|
||||
|
||||
export class RaceDetailEntryDTO {
|
||||
@ApiProperty()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
107
apps/api/src/domain/race/presenters/RaceDetailPresenter.ts
Normal file
107
apps/api/src/domain/race/presenters/RaceDetailPresenter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
43
apps/api/src/domain/race/presenters/RaceProtestsPresenter.ts
Normal file
43
apps/api/src/domain/race/presenters/RaceProtestsPresenter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
26
apps/api/src/domain/race/presenters/RaceWithSOFPresenter.ts
Normal file
26
apps/api/src/domain/race/presenters/RaceWithSOFPresenter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,11 @@ describe('SponsorController', () => {
|
||||
getPendingSponsorshipRequests: vi.fn(),
|
||||
acceptSponsorshipRequest: vi.fn(),
|
||||
rejectSponsorshipRequest: vi.fn(),
|
||||
getSponsorBilling: vi.fn(),
|
||||
getAvailableLeagues: vi.fn(),
|
||||
getLeagueDetail: vi.fn(),
|
||||
getSponsorSettings: vi.fn(),
|
||||
updateSponsorSettings: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -35,7 +40,7 @@ describe('SponsorController', () => {
|
||||
describe('getEntitySponsorshipPricing', () => {
|
||||
it('should return sponsorship pricing', async () => {
|
||||
const mockResult = { entityType: 'season', entityId: 'season-1', pricing: [] };
|
||||
sponsorService.getEntitySponsorshipPricing.mockResolvedValue(mockResult);
|
||||
sponsorService.getEntitySponsorshipPricing.mockResolvedValue({ viewModel: mockResult } as any);
|
||||
|
||||
const result = await controller.getEntitySponsorshipPricing();
|
||||
|
||||
@@ -47,7 +52,7 @@ describe('SponsorController', () => {
|
||||
describe('getSponsors', () => {
|
||||
it('should return sponsors list', async () => {
|
||||
const mockResult = { sponsors: [] };
|
||||
sponsorService.getSponsors.mockResolvedValue(mockResult);
|
||||
sponsorService.getSponsors.mockResolvedValue({ viewModel: mockResult } as any);
|
||||
|
||||
const result = await controller.getSponsors();
|
||||
|
||||
@@ -59,10 +64,10 @@ describe('SponsorController', () => {
|
||||
describe('createSponsor', () => {
|
||||
it('should create sponsor', async () => {
|
||||
const input = { name: 'Test Sponsor', contactEmail: 'test@example.com' };
|
||||
const mockResult = { id: 'sponsor-1', name: 'Test Sponsor' };
|
||||
sponsorService.createSponsor.mockResolvedValue(mockResult);
|
||||
const mockResult = { sponsor: { id: 's1', name: 'Test Sponsor' } };
|
||||
sponsorService.createSponsor.mockResolvedValue({ viewModel: mockResult } as any);
|
||||
|
||||
const result = await controller.createSponsor(input);
|
||||
const result = await controller.createSponsor(input as any);
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(sponsorService.createSponsor).toHaveBeenCalledWith(input);
|
||||
@@ -71,9 +76,9 @@ describe('SponsorController', () => {
|
||||
|
||||
describe('getSponsorDashboard', () => {
|
||||
it('should return sponsor dashboard', async () => {
|
||||
const sponsorId = 'sponsor-1';
|
||||
const mockResult = { sponsorId, metrics: {}, sponsoredLeagues: [] };
|
||||
sponsorService.getSponsorDashboard.mockResolvedValue(mockResult);
|
||||
const sponsorId = 's1';
|
||||
const mockResult = { sponsorId, metrics: {} as any, sponsoredLeagues: [], investment: {} as any };
|
||||
sponsorService.getSponsorDashboard.mockResolvedValue({ viewModel: mockResult } as any);
|
||||
|
||||
const result = await controller.getSponsorDashboard(sponsorId);
|
||||
|
||||
@@ -82,8 +87,8 @@ describe('SponsorController', () => {
|
||||
});
|
||||
|
||||
it('should return null when sponsor not found', async () => {
|
||||
const sponsorId = 'sponsor-1';
|
||||
sponsorService.getSponsorDashboard.mockResolvedValue(null);
|
||||
const sponsorId = 's1';
|
||||
sponsorService.getSponsorDashboard.mockResolvedValue({ viewModel: null } as any);
|
||||
|
||||
const result = await controller.getSponsorDashboard(sponsorId);
|
||||
|
||||
@@ -93,9 +98,20 @@ describe('SponsorController', () => {
|
||||
|
||||
describe('getSponsorSponsorships', () => {
|
||||
it('should return sponsor sponsorships', async () => {
|
||||
const sponsorId = 'sponsor-1';
|
||||
const mockResult = { sponsorId, sponsorships: [] };
|
||||
sponsorService.getSponsorSponsorships.mockResolvedValue(mockResult);
|
||||
const sponsorId = 's1';
|
||||
const mockResult = {
|
||||
sponsorId,
|
||||
sponsorName: 'S1',
|
||||
sponsorships: [],
|
||||
summary: {
|
||||
totalSponsorships: 0,
|
||||
activeSponsorships: 0,
|
||||
totalInvestment: 0,
|
||||
totalPlatformFees: 0,
|
||||
currency: 'USD',
|
||||
},
|
||||
};
|
||||
sponsorService.getSponsorSponsorships.mockResolvedValue({ viewModel: mockResult } as any);
|
||||
|
||||
const result = await controller.getSponsorSponsorships(sponsorId);
|
||||
|
||||
@@ -104,8 +120,8 @@ describe('SponsorController', () => {
|
||||
});
|
||||
|
||||
it('should return null when sponsor not found', async () => {
|
||||
const sponsorId = 'sponsor-1';
|
||||
sponsorService.getSponsorSponsorships.mockResolvedValue(null);
|
||||
const sponsorId = 's1';
|
||||
sponsorService.getSponsorSponsorships.mockResolvedValue({ viewModel: null } as any);
|
||||
|
||||
const result = await controller.getSponsorSponsorships(sponsorId);
|
||||
|
||||
@@ -115,9 +131,9 @@ describe('SponsorController', () => {
|
||||
|
||||
describe('getSponsor', () => {
|
||||
it('should return sponsor', async () => {
|
||||
const sponsorId = 'sponsor-1';
|
||||
const mockResult = { id: sponsorId, name: 'Test Sponsor' };
|
||||
sponsorService.getSponsor.mockResolvedValue(mockResult);
|
||||
const sponsorId = 's1';
|
||||
const mockResult = { sponsor: { id: sponsorId, name: 'S1' } };
|
||||
sponsorService.getSponsor.mockResolvedValue({ viewModel: mockResult } as any);
|
||||
|
||||
const result = await controller.getSponsor(sponsorId);
|
||||
|
||||
@@ -126,8 +142,8 @@ describe('SponsorController', () => {
|
||||
});
|
||||
|
||||
it('should return null when sponsor not found', async () => {
|
||||
const sponsorId = 'sponsor-1';
|
||||
sponsorService.getSponsor.mockResolvedValue(null);
|
||||
const sponsorId = 's1';
|
||||
sponsorService.getSponsor.mockResolvedValue({ viewModel: null } as any);
|
||||
|
||||
const result = await controller.getSponsor(sponsorId);
|
||||
|
||||
@@ -138,8 +154,13 @@ describe('SponsorController', () => {
|
||||
describe('getPendingSponsorshipRequests', () => {
|
||||
it('should return pending sponsorship requests', async () => {
|
||||
const query = { entityType: 'season' as const, entityId: 'season-1' };
|
||||
const mockResult = { entityType: 'season', entityId: 'season-1', requests: [], totalCount: 0 };
|
||||
sponsorService.getPendingSponsorshipRequests.mockResolvedValue(mockResult);
|
||||
const mockResult = {
|
||||
entityType: 'season',
|
||||
entityId: 'season-1',
|
||||
requests: [],
|
||||
totalCount: 0,
|
||||
};
|
||||
sponsorService.getPendingSponsorshipRequests.mockResolvedValue({ viewModel: mockResult } as any);
|
||||
|
||||
const result = await controller.getPendingSponsorshipRequests(query);
|
||||
|
||||
@@ -150,30 +171,33 @@ describe('SponsorController', () => {
|
||||
|
||||
describe('acceptSponsorshipRequest', () => {
|
||||
it('should accept sponsorship request', async () => {
|
||||
const requestId = 'request-1';
|
||||
const input = { respondedBy: 'user-1' };
|
||||
const requestId = 'r1';
|
||||
const input = { respondedBy: 'u1' };
|
||||
const mockResult = {
|
||||
requestId,
|
||||
sponsorshipId: 'sponsorship-1',
|
||||
sponsorshipId: 'sp1',
|
||||
status: 'accepted' as const,
|
||||
acceptedAt: new Date(),
|
||||
platformFee: 10,
|
||||
netAmount: 90,
|
||||
};
|
||||
sponsorService.acceptSponsorshipRequest.mockResolvedValue(mockResult);
|
||||
sponsorService.acceptSponsorshipRequest.mockResolvedValue({ viewModel: mockResult } as any);
|
||||
|
||||
const result = await controller.acceptSponsorshipRequest(requestId, input);
|
||||
const result = await controller.acceptSponsorshipRequest(requestId, input as any);
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(sponsorService.acceptSponsorshipRequest).toHaveBeenCalledWith(requestId, input.respondedBy);
|
||||
expect(sponsorService.acceptSponsorshipRequest).toHaveBeenCalledWith(
|
||||
requestId,
|
||||
input.respondedBy,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null on error', async () => {
|
||||
const requestId = 'request-1';
|
||||
const input = { respondedBy: 'user-1' };
|
||||
sponsorService.acceptSponsorshipRequest.mockResolvedValue(null);
|
||||
const requestId = 'r1';
|
||||
const input = { respondedBy: 'u1' };
|
||||
sponsorService.acceptSponsorshipRequest.mockResolvedValue({ viewModel: null } as any);
|
||||
|
||||
const result = await controller.acceptSponsorshipRequest(requestId, input);
|
||||
const result = await controller.acceptSponsorshipRequest(requestId, input as any);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@@ -181,30 +205,118 @@ describe('SponsorController', () => {
|
||||
|
||||
describe('rejectSponsorshipRequest', () => {
|
||||
it('should reject sponsorship request', async () => {
|
||||
const requestId = 'request-1';
|
||||
const input = { respondedBy: 'user-1', reason: 'Not interested' };
|
||||
const requestId = 'r1';
|
||||
const input = { respondedBy: 'u1', reason: 'Not interested' };
|
||||
const mockResult = {
|
||||
requestId,
|
||||
status: 'rejected' as const,
|
||||
rejectedAt: new Date(),
|
||||
reason: 'Not interested',
|
||||
};
|
||||
sponsorService.rejectSponsorshipRequest.mockResolvedValue(mockResult);
|
||||
sponsorService.rejectSponsorshipRequest.mockResolvedValue({ viewModel: mockResult } as any);
|
||||
|
||||
const result = await controller.rejectSponsorshipRequest(requestId, input);
|
||||
const result = await controller.rejectSponsorshipRequest(requestId, input as any);
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(sponsorService.rejectSponsorshipRequest).toHaveBeenCalledWith(requestId, input.respondedBy, input.reason);
|
||||
expect(sponsorService.rejectSponsorshipRequest).toHaveBeenCalledWith(
|
||||
requestId,
|
||||
input.respondedBy,
|
||||
input.reason,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null on error', async () => {
|
||||
const requestId = 'request-1';
|
||||
const input = { respondedBy: 'user-1' };
|
||||
sponsorService.rejectSponsorshipRequest.mockResolvedValue(null);
|
||||
const requestId = 'r1';
|
||||
const input = { respondedBy: 'u1' };
|
||||
sponsorService.rejectSponsorshipRequest.mockResolvedValue({ viewModel: null } as any);
|
||||
|
||||
const result = await controller.rejectSponsorshipRequest(requestId, input);
|
||||
const result = await controller.rejectSponsorshipRequest(requestId, input as any);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSponsorBilling', () => {
|
||||
it('should return sponsor billing', async () => {
|
||||
const sponsorId = 's1';
|
||||
const mockResult = {
|
||||
paymentMethods: [],
|
||||
invoices: [],
|
||||
stats: {
|
||||
totalSpent: 0,
|
||||
pendingAmount: 0,
|
||||
nextPaymentDate: '2025-01-01',
|
||||
nextPaymentAmount: 0,
|
||||
activeSponsorships: 0,
|
||||
averageMonthlySpend: 0,
|
||||
},
|
||||
};
|
||||
sponsorService.getSponsorBilling.mockResolvedValue({ viewModel: mockResult } as any);
|
||||
|
||||
const result = await controller.getSponsorBilling(sponsorId);
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(sponsorService.getSponsorBilling).toHaveBeenCalledWith(sponsorId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailableLeagues', () => {
|
||||
it('should return available leagues', async () => {
|
||||
const mockResult: any[] = [];
|
||||
sponsorService.getAvailableLeagues.mockResolvedValue({ viewModel: mockResult } as any);
|
||||
|
||||
const result = await controller.getAvailableLeagues();
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(sponsorService.getAvailableLeagues).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLeagueDetail', () => {
|
||||
it('should return league detail', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const mockResult = {
|
||||
league: { id: leagueId } as any,
|
||||
drivers: [],
|
||||
races: [],
|
||||
};
|
||||
sponsorService.getLeagueDetail.mockResolvedValue({ viewModel: mockResult } as any);
|
||||
|
||||
const result = await controller.getLeagueDetail(leagueId);
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(sponsorService.getLeagueDetail).toHaveBeenCalledWith(leagueId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSponsorSettings', () => {
|
||||
it('should return sponsor settings', async () => {
|
||||
const sponsorId = 's1';
|
||||
const mockResult = {
|
||||
profile: {} as any,
|
||||
notifications: {} as any,
|
||||
privacy: {} as any,
|
||||
};
|
||||
sponsorService.getSponsorSettings.mockResolvedValue({ viewModel: mockResult } as any);
|
||||
|
||||
const result = await controller.getSponsorSettings(sponsorId);
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(sponsorService.getSponsorSettings).toHaveBeenCalledWith(sponsorId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSponsorSettings', () => {
|
||||
it('should update sponsor settings', async () => {
|
||||
const sponsorId = 's1';
|
||||
const input = {};
|
||||
const mockResult = { success: true };
|
||||
sponsorService.updateSponsorSettings.mockResolvedValue({ viewModel: mockResult } as any);
|
||||
|
||||
const result = await controller.updateSponsorSettings(sponsorId, input);
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(sponsorService.updateSponsorSettings).toHaveBeenCalledWith(sponsorId, input);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ import { RaceDTO } from './dtos/RaceDTO';
|
||||
import { SponsorProfileDTO } from './dtos/SponsorProfileDTO';
|
||||
import { NotificationSettingsDTO } from './dtos/NotificationSettingsDTO';
|
||||
import { PrivacySettingsDTO } from './dtos/PrivacySettingsDTO';
|
||||
import type { AcceptSponsorshipRequestResultPort } from '@core/racing/application/ports/output/AcceptSponsorshipRequestResultPort';
|
||||
import type { AcceptSponsorshipRequestResultViewModel } from './presenters/AcceptSponsorshipRequestPresenter';
|
||||
import type { RejectSponsorshipRequestResultDTO } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
|
||||
|
||||
@ApiTags('sponsors')
|
||||
@@ -33,129 +33,212 @@ export class SponsorController {
|
||||
|
||||
@Get('pricing')
|
||||
@ApiOperation({ summary: 'Get sponsorship pricing for an entity' })
|
||||
@ApiResponse({ status: 200, description: 'Sponsorship pricing', type: GetEntitySponsorshipPricingResultDTO })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Sponsorship pricing',
|
||||
type: GetEntitySponsorshipPricingResultDTO,
|
||||
})
|
||||
async getEntitySponsorshipPricing(): Promise<GetEntitySponsorshipPricingResultDTO> {
|
||||
return this.sponsorService.getEntitySponsorshipPricing();
|
||||
const presenter = await this.sponsorService.getEntitySponsorshipPricing();
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get all sponsors' })
|
||||
@ApiResponse({ status: 200, description: 'List of sponsors', type: GetSponsorsOutputDTO })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'List of sponsors',
|
||||
type: GetSponsorsOutputDTO,
|
||||
})
|
||||
async getSponsors(): Promise<GetSponsorsOutputDTO> {
|
||||
return this.sponsorService.getSponsors();
|
||||
const presenter = await this.sponsorService.getSponsors();
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Create a new sponsor' })
|
||||
@ApiResponse({ status: 201, description: 'Sponsor created', type: CreateSponsorOutputDTO })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Sponsor created',
|
||||
type: CreateSponsorOutputDTO,
|
||||
})
|
||||
async createSponsor(@Body() input: CreateSponsorInputDTO): Promise<CreateSponsorOutputDTO> {
|
||||
return this.sponsorService.createSponsor(input);
|
||||
const presenter = await this.sponsorService.createSponsor(input);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
// Add other Sponsor endpoints here based on other presenters
|
||||
@Get('dashboard/:sponsorId')
|
||||
@ApiOperation({ summary: 'Get sponsor dashboard metrics and sponsored leagues' })
|
||||
@ApiResponse({ status: 200, description: 'Sponsor dashboard data', type: SponsorDashboardDTO })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Sponsor dashboard data',
|
||||
type: SponsorDashboardDTO,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Sponsor not found' })
|
||||
async getSponsorDashboard(@Param('sponsorId') sponsorId: string): Promise<SponsorDashboardDTO | null> {
|
||||
return this.sponsorService.getSponsorDashboard({ sponsorId } as GetSponsorDashboardQueryParamsDTO);
|
||||
async getSponsorDashboard(
|
||||
@Param('sponsorId') sponsorId: string,
|
||||
): Promise<SponsorDashboardDTO | null> {
|
||||
const presenter = await this.sponsorService.getSponsorDashboard({
|
||||
sponsorId,
|
||||
} as GetSponsorDashboardQueryParamsDTO);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Get(':sponsorId/sponsorships')
|
||||
@ApiOperation({ summary: 'Get all sponsorships for a given sponsor' })
|
||||
@ApiResponse({ status: 200, description: 'List of sponsorships', type: SponsorSponsorshipsDTO })
|
||||
@ApiOperation({
|
||||
summary: 'Get all sponsorships for a given sponsor',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'List of sponsorships',
|
||||
type: SponsorSponsorshipsDTO,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Sponsor not found' })
|
||||
async getSponsorSponsorships(@Param('sponsorId') sponsorId: string): Promise<SponsorSponsorshipsDTO | null> {
|
||||
return this.sponsorService.getSponsorSponsorships({ sponsorId } as GetSponsorSponsorshipsQueryParamsDTO);
|
||||
async getSponsorSponsorships(
|
||||
@Param('sponsorId') sponsorId: string,
|
||||
): Promise<SponsorSponsorshipsDTO | null> {
|
||||
const presenter = await this.sponsorService.getSponsorSponsorships({
|
||||
sponsorId,
|
||||
} as GetSponsorSponsorshipsQueryParamsDTO);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Get(':sponsorId')
|
||||
@ApiOperation({ summary: 'Get a sponsor by ID' })
|
||||
@ApiResponse({ status: 200, description: 'Sponsor data', type: GetSponsorOutputDTO })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Sponsor data',
|
||||
type: GetSponsorOutputDTO,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Sponsor not found' })
|
||||
async getSponsor(@Param('sponsorId') sponsorId: string): Promise<GetSponsorOutputDTO | null> {
|
||||
return this.sponsorService.getSponsor(sponsorId);
|
||||
const presenter = await this.sponsorService.getSponsor(sponsorId);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Get('requests')
|
||||
@ApiOperation({ summary: 'Get pending sponsorship requests' })
|
||||
@ApiResponse({ status: 200, description: 'List of pending sponsorship requests', type: GetPendingSponsorshipRequestsOutputDTO })
|
||||
async getPendingSponsorshipRequests(@Query() query: { entityType: string; entityId: string }): Promise<GetPendingSponsorshipRequestsOutputDTO> {
|
||||
return this.sponsorService.getPendingSponsorshipRequests(query as { entityType: import('@core/racing/domain/entities/SponsorshipRequest').SponsorableEntityType; entityId: string });
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'List of pending sponsorship requests',
|
||||
type: GetPendingSponsorshipRequestsOutputDTO,
|
||||
})
|
||||
async getPendingSponsorshipRequests(
|
||||
@Query() query: { entityType: string; entityId: string },
|
||||
): Promise<GetPendingSponsorshipRequestsOutputDTO | null> {
|
||||
const presenter = await this.sponsorService.getPendingSponsorshipRequests(
|
||||
query as {
|
||||
entityType: import('@core/racing/domain/entities/SponsorshipRequest').SponsorableEntityType;
|
||||
entityId: string;
|
||||
},
|
||||
);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Post('requests/:requestId/accept')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Accept a sponsorship request' })
|
||||
@ApiResponse({ status: 200, description: 'Sponsorship request accepted' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid request' })
|
||||
@ApiResponse({ status: 404, description: 'Request not found' })
|
||||
async acceptSponsorshipRequest(@Param('requestId') requestId: string, @Body() input: AcceptSponsorshipRequestInputDTO): Promise<AcceptSponsorshipRequestResultPort | null> {
|
||||
return this.sponsorService.acceptSponsorshipRequest(requestId, input.respondedBy);
|
||||
}
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Accept a sponsorship request' })
|
||||
@ApiResponse({ status: 200, description: 'Sponsorship request accepted' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid request' })
|
||||
@ApiResponse({ status: 404, description: 'Request not found' })
|
||||
async acceptSponsorshipRequest(
|
||||
@Param('requestId') requestId: string,
|
||||
@Body() input: AcceptSponsorshipRequestInputDTO,
|
||||
): Promise<AcceptSponsorshipRequestResultViewModel | null> {
|
||||
const presenter = await this.sponsorService.acceptSponsorshipRequest(
|
||||
requestId,
|
||||
input.respondedBy,
|
||||
);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Post('requests/:requestId/reject')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Reject a sponsorship request' })
|
||||
@ApiResponse({ status: 200, description: 'Sponsorship request rejected' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid request' })
|
||||
@ApiResponse({ status: 404, description: 'Request not found' })
|
||||
async rejectSponsorshipRequest(@Param('requestId') requestId: string, @Body() input: RejectSponsorshipRequestInputDTO): Promise<RejectSponsorshipRequestResultDTO | null> {
|
||||
return this.sponsorService.rejectSponsorshipRequest(requestId, input.respondedBy, input.reason);
|
||||
}
|
||||
@Post('requests/:requestId/reject')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Reject a sponsorship request' })
|
||||
@ApiResponse({ status: 200, description: 'Sponsorship request rejected' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid request' })
|
||||
@ApiResponse({ status: 404, description: 'Request not found' })
|
||||
async rejectSponsorshipRequest(
|
||||
@Param('requestId') requestId: string,
|
||||
@Body() input: RejectSponsorshipRequestInputDTO,
|
||||
): Promise<RejectSponsorshipRequestResultDTO | null> {
|
||||
const presenter = await this.sponsorService.rejectSponsorshipRequest(
|
||||
requestId,
|
||||
input.respondedBy,
|
||||
input.reason,
|
||||
);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Get('billing/:sponsorId')
|
||||
@ApiOperation({ summary: 'Get sponsor billing information' })
|
||||
@ApiResponse({ status: 200, description: 'Sponsor billing data', type: Object })
|
||||
async getSponsorBilling(@Param('sponsorId') sponsorId: string): Promise<{
|
||||
paymentMethods: PaymentMethodDTO[];
|
||||
invoices: InvoiceDTO[];
|
||||
stats: BillingStatsDTO;
|
||||
}> {
|
||||
return this.sponsorService.getSponsorBilling(sponsorId);
|
||||
}
|
||||
@Get('billing/:sponsorId')
|
||||
@ApiOperation({ summary: 'Get sponsor billing information' })
|
||||
@ApiResponse({ status: 200, description: 'Sponsor billing data', type: Object })
|
||||
async getSponsorBilling(
|
||||
@Param('sponsorId') sponsorId: string,
|
||||
): Promise<{
|
||||
paymentMethods: PaymentMethodDTO[];
|
||||
invoices: InvoiceDTO[];
|
||||
stats: BillingStatsDTO;
|
||||
}> {
|
||||
const presenter = await this.sponsorService.getSponsorBilling(sponsorId);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Get('leagues/available')
|
||||
@ApiOperation({ summary: 'Get available leagues for sponsorship' })
|
||||
@ApiResponse({ status: 200, description: 'Available leagues', type: [AvailableLeagueDTO] })
|
||||
async getAvailableLeagues(): Promise<AvailableLeagueDTO[]> {
|
||||
return this.sponsorService.getAvailableLeagues();
|
||||
}
|
||||
@Get('leagues/available')
|
||||
@ApiOperation({ summary: 'Get available leagues for sponsorship' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Available leagues',
|
||||
type: [AvailableLeagueDTO],
|
||||
})
|
||||
async getAvailableLeagues(): Promise<AvailableLeagueDTO[] | null> {
|
||||
const presenter = await this.sponsorService.getAvailableLeagues();
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Get('leagues/:leagueId/detail')
|
||||
@ApiOperation({ summary: 'Get detailed league information for sponsors' })
|
||||
@ApiResponse({ status: 200, description: 'League detail data', type: Object })
|
||||
async getLeagueDetail(@Param('leagueId') leagueId: string): Promise<{
|
||||
league: LeagueDetailDTO;
|
||||
drivers: DriverDTO[];
|
||||
races: RaceDTO[];
|
||||
}> {
|
||||
return this.sponsorService.getLeagueDetail(leagueId);
|
||||
}
|
||||
@Get('leagues/:leagueId/detail')
|
||||
@ApiOperation({ summary: 'Get detailed league information for sponsors' })
|
||||
@ApiResponse({ status: 200, description: 'League detail data', type: Object })
|
||||
async getLeagueDetail(
|
||||
@Param('leagueId') leagueId: string,
|
||||
): Promise<{
|
||||
league: LeagueDetailDTO;
|
||||
drivers: DriverDTO[];
|
||||
races: RaceDTO[];
|
||||
} | null> {
|
||||
const presenter = await this.sponsorService.getLeagueDetail(leagueId);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Get('settings/:sponsorId')
|
||||
@ApiOperation({ summary: 'Get sponsor settings' })
|
||||
@ApiResponse({ status: 200, description: 'Sponsor settings', type: Object })
|
||||
async getSponsorSettings(@Param('sponsorId') sponsorId: string): Promise<{
|
||||
profile: SponsorProfileDTO;
|
||||
notifications: NotificationSettingsDTO;
|
||||
privacy: PrivacySettingsDTO;
|
||||
}> {
|
||||
return this.sponsorService.getSponsorSettings(sponsorId);
|
||||
}
|
||||
@Get('settings/:sponsorId')
|
||||
@ApiOperation({ summary: 'Get sponsor settings' })
|
||||
@ApiResponse({ status: 200, description: 'Sponsor settings', type: Object })
|
||||
async getSponsorSettings(
|
||||
@Param('sponsorId') sponsorId: string,
|
||||
): Promise<{
|
||||
profile: SponsorProfileDTO;
|
||||
notifications: NotificationSettingsDTO;
|
||||
privacy: PrivacySettingsDTO;
|
||||
} | null> {
|
||||
const presenter = await this.sponsorService.getSponsorSettings(sponsorId);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
|
||||
@Put('settings/:sponsorId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Update sponsor settings' })
|
||||
@ApiResponse({ status: 200, description: 'Settings updated successfully' })
|
||||
async updateSponsorSettings(
|
||||
@Param('sponsorId') sponsorId: string,
|
||||
@Body() input: {
|
||||
profile?: Partial<SponsorProfileDTO>;
|
||||
notifications?: Partial<NotificationSettingsDTO>;
|
||||
privacy?: Partial<PrivacySettingsDTO>;
|
||||
}
|
||||
): Promise<void> {
|
||||
return this.sponsorService.updateSponsorSettings(sponsorId, input);
|
||||
}
|
||||
@Put('settings/:sponsorId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Update sponsor settings' })
|
||||
@ApiResponse({ status: 200, description: 'Settings updated successfully' })
|
||||
async updateSponsorSettings(
|
||||
@Param('sponsorId') sponsorId: string,
|
||||
@Body()
|
||||
input: {
|
||||
profile?: Partial<SponsorProfileDTO>;
|
||||
notifications?: Partial<NotificationSettingsDTO>;
|
||||
privacy?: Partial<PrivacySettingsDTO>;
|
||||
},
|
||||
): Promise<{ success: boolean; errorCode?: string; message?: string } | null> {
|
||||
const presenter = await this.sponsorService.updateSponsorSettings(sponsorId, input);
|
||||
return presenter.viewModel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
|
||||
import { SponsorService } from './SponsorService';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { GetSponsorshipPricingUseCase } from '@core/racing/application/use-cases/GetSponsorshipPricingUseCase';
|
||||
import type { GetSponsorsUseCase } from '@core/racing/application/use-cases/GetSponsorsUseCase';
|
||||
import type { CreateSponsorUseCase } from '@core/racing/application/use-cases/CreateSponsorUseCase';
|
||||
@@ -9,8 +10,7 @@ import type { GetSponsorUseCase } from '@core/racing/application/use-cases/GetSp
|
||||
import type { GetPendingSponsorshipRequestsUseCase } from '@core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
|
||||
import type { AcceptSponsorshipRequestUseCase } from '@core/racing/application/use-cases/AcceptSponsorshipRequestUseCase';
|
||||
import type { RejectSponsorshipRequestUseCase } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import { SponsorService } from './SponsorService';
|
||||
|
||||
describe('SponsorService', () => {
|
||||
let service: SponsorService;
|
||||
@@ -23,12 +23,7 @@ describe('SponsorService', () => {
|
||||
let getPendingSponsorshipRequestsUseCase: { execute: Mock };
|
||||
let acceptSponsorshipRequestUseCase: { execute: Mock };
|
||||
let rejectSponsorshipRequestUseCase: { execute: Mock };
|
||||
let logger: {
|
||||
debug: Mock;
|
||||
info: Mock;
|
||||
warn: Mock;
|
||||
error: Mock;
|
||||
};
|
||||
let logger: Logger;
|
||||
|
||||
beforeEach(() => {
|
||||
getSponsorshipPricingUseCase = { execute: vi.fn() };
|
||||
@@ -45,7 +40,7 @@ describe('SponsorService', () => {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
} as unknown as Logger;
|
||||
|
||||
service = new SponsorService(
|
||||
getSponsorshipPricingUseCase as unknown as GetSponsorshipPricingUseCase,
|
||||
@@ -57,136 +52,199 @@ describe('SponsorService', () => {
|
||||
getPendingSponsorshipRequestsUseCase as unknown as GetPendingSponsorshipRequestsUseCase,
|
||||
acceptSponsorshipRequestUseCase as unknown as AcceptSponsorshipRequestUseCase,
|
||||
rejectSponsorshipRequestUseCase as unknown as RejectSponsorshipRequestUseCase,
|
||||
logger as unknown as Logger,
|
||||
logger,
|
||||
);
|
||||
});
|
||||
|
||||
describe('getEntitySponsorshipPricing', () => {
|
||||
it('should return sponsorship pricing', async () => {
|
||||
const mockPresenter = {
|
||||
viewModel: { entityType: 'season', entityId: 'season-1', pricing: [] },
|
||||
it('returns presenter with pricing data on success', async () => {
|
||||
const outputPort = {
|
||||
entityType: 'season',
|
||||
entityId: 'season-1',
|
||||
pricing: [
|
||||
{ id: 'tier-gold', level: 'Gold', price: 500, currency: 'USD' },
|
||||
],
|
||||
};
|
||||
getSponsorshipPricingUseCase.execute.mockResolvedValue(undefined);
|
||||
getSponsorshipPricingUseCase.execute.mockResolvedValue(Result.ok(outputPort));
|
||||
|
||||
// Mock the presenter
|
||||
const originalGetSponsorshipPricingPresenter = await import('./presenters/GetSponsorshipPricingPresenter');
|
||||
const mockPresenterClass = vi.fn().mockImplementation(() => mockPresenter);
|
||||
vi.doMock('./presenters/GetSponsorshipPricingPresenter', () => ({
|
||||
GetSponsorshipPricingPresenter: mockPresenterClass,
|
||||
}));
|
||||
const presenter = await service.getEntitySponsorshipPricing();
|
||||
|
||||
const result = await service.getEntitySponsorshipPricing();
|
||||
expect(presenter.viewModel).toEqual({
|
||||
entityType: 'season',
|
||||
entityId: 'season-1',
|
||||
pricing: [
|
||||
{ id: 'tier-gold', level: 'Gold', price: 500, currency: 'USD' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockPresenter.viewModel);
|
||||
expect(getSponsorshipPricingUseCase.execute).toHaveBeenCalledWith(undefined, mockPresenter);
|
||||
it('returns empty pricing on error', async () => {
|
||||
getSponsorshipPricingUseCase.execute.mockResolvedValue(Result.err({ code: 'REPOSITORY_ERROR' }));
|
||||
|
||||
const presenter = await service.getEntitySponsorshipPricing();
|
||||
|
||||
expect(presenter.viewModel).toEqual({
|
||||
entityType: 'season',
|
||||
entityId: '',
|
||||
pricing: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSponsors', () => {
|
||||
it('should return sponsors list', async () => {
|
||||
const mockPresenter = {
|
||||
viewModel: { sponsors: [] },
|
||||
};
|
||||
getSponsorsUseCase.execute.mockResolvedValue(undefined);
|
||||
it('returns sponsors in presenter on success', async () => {
|
||||
const outputPort = { sponsors: [{ id: 's1', name: 'S1', contactEmail: 's1@test', createdAt: new Date() }] };
|
||||
getSponsorsUseCase.execute.mockResolvedValue(Result.ok(outputPort));
|
||||
|
||||
const result = await service.getSponsors();
|
||||
const presenter = await service.getSponsors();
|
||||
|
||||
expect(result).toEqual(mockPresenter.viewModel);
|
||||
expect(getSponsorsUseCase.execute).toHaveBeenCalledWith(undefined, expect.any(Object));
|
||||
expect(presenter.viewModel).toEqual({ sponsors: outputPort.sponsors });
|
||||
});
|
||||
|
||||
it('returns empty list on error', async () => {
|
||||
getSponsorsUseCase.execute.mockResolvedValue(Result.err({ code: 'REPOSITORY_ERROR' }));
|
||||
|
||||
const presenter = await service.getSponsors();
|
||||
|
||||
expect(presenter.viewModel).toEqual({ sponsors: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSponsor', () => {
|
||||
it('should create sponsor successfully', async () => {
|
||||
const input = { name: 'Test Sponsor', contactEmail: 'test@example.com' };
|
||||
const mockPresenter = {
|
||||
viewModel: { id: 'sponsor-1', name: 'Test Sponsor' },
|
||||
it('returns created sponsor in presenter on success', async () => {
|
||||
const input = { name: 'Test', contactEmail: 'test@example.com' };
|
||||
const outputPort = {
|
||||
sponsor: {
|
||||
id: 's1',
|
||||
name: 'Test',
|
||||
contactEmail: 'test@example.com',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
};
|
||||
createSponsorUseCase.execute.mockResolvedValue(undefined);
|
||||
createSponsorUseCase.execute.mockResolvedValue(Result.ok(outputPort));
|
||||
|
||||
const result = await service.createSponsor(input);
|
||||
const presenter = await service.createSponsor(input as any);
|
||||
|
||||
expect(result).toEqual(mockPresenter.viewModel);
|
||||
expect(createSponsorUseCase.execute).toHaveBeenCalledWith(input, expect.any(Object));
|
||||
expect(presenter.viewModel).toEqual({ sponsor: outputPort.sponsor });
|
||||
});
|
||||
|
||||
it('throws on error', async () => {
|
||||
const input = { name: 'Test', contactEmail: 'test@example.com' };
|
||||
createSponsorUseCase.execute.mockResolvedValue(
|
||||
Result.err({ code: 'VALIDATION_ERROR', details: { message: 'Invalid' } }),
|
||||
);
|
||||
|
||||
await expect(service.createSponsor(input as any)).rejects.toThrow('Invalid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSponsorDashboard', () => {
|
||||
it('should return sponsor dashboard', async () => {
|
||||
const params = { sponsorId: 'sponsor-1' };
|
||||
const mockPresenter = {
|
||||
viewModel: { sponsorId: 'sponsor-1', metrics: {}, sponsoredLeagues: [] },
|
||||
it('returns dashboard in presenter on success', async () => {
|
||||
const params = { sponsorId: 's1' };
|
||||
const outputPort = {
|
||||
sponsorId: 's1',
|
||||
sponsorName: 'S1',
|
||||
metrics: {} as any,
|
||||
sponsoredLeagues: [],
|
||||
investment: {} as any,
|
||||
};
|
||||
getSponsorDashboardUseCase.execute.mockResolvedValue(undefined);
|
||||
getSponsorDashboardUseCase.execute.mockResolvedValue(Result.ok(outputPort));
|
||||
|
||||
const result = await service.getSponsorDashboard(params);
|
||||
const presenter = await service.getSponsorDashboard(params as any);
|
||||
|
||||
expect(result).toEqual(mockPresenter.viewModel);
|
||||
expect(getSponsorDashboardUseCase.execute).toHaveBeenCalledWith(params, expect.any(Object));
|
||||
expect(presenter.viewModel).toEqual(outputPort);
|
||||
});
|
||||
|
||||
it('returns null in presenter on error', async () => {
|
||||
const params = { sponsorId: 's1' };
|
||||
getSponsorDashboardUseCase.execute.mockResolvedValue(Result.err({ code: 'REPOSITORY_ERROR' }));
|
||||
|
||||
const presenter = await service.getSponsorDashboard(params as any);
|
||||
|
||||
expect(presenter.viewModel).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSponsorSponsorships', () => {
|
||||
it('should return sponsor sponsorships', async () => {
|
||||
const params = { sponsorId: 'sponsor-1' };
|
||||
const mockPresenter = {
|
||||
viewModel: { sponsorId: 'sponsor-1', sponsorships: [] },
|
||||
it('returns sponsorships in presenter on success', async () => {
|
||||
const params = { sponsorId: 's1' };
|
||||
const outputPort = {
|
||||
sponsorId: 's1',
|
||||
sponsorName: 'S1',
|
||||
sponsorships: [],
|
||||
summary: {
|
||||
totalSponsorships: 0,
|
||||
activeSponsorships: 0,
|
||||
totalInvestment: 0,
|
||||
totalPlatformFees: 0,
|
||||
currency: 'USD',
|
||||
},
|
||||
};
|
||||
getSponsorSponsorshipsUseCase.execute.mockResolvedValue(undefined);
|
||||
getSponsorSponsorshipsUseCase.execute.mockResolvedValue(Result.ok(outputPort));
|
||||
|
||||
const result = await service.getSponsorSponsorships(params);
|
||||
const presenter = await service.getSponsorSponsorships(params as any);
|
||||
|
||||
expect(result).toEqual(mockPresenter.viewModel);
|
||||
expect(getSponsorSponsorshipsUseCase.execute).toHaveBeenCalledWith(params, expect.any(Object));
|
||||
expect(presenter.viewModel).toEqual(outputPort);
|
||||
});
|
||||
|
||||
it('returns null in presenter on error', async () => {
|
||||
const params = { sponsorId: 's1' };
|
||||
getSponsorSponsorshipsUseCase.execute.mockResolvedValue(
|
||||
Result.err({ code: 'REPOSITORY_ERROR' }),
|
||||
);
|
||||
|
||||
const presenter = await service.getSponsorSponsorships(params as any);
|
||||
|
||||
expect(presenter.viewModel).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSponsor', () => {
|
||||
it('should return sponsor when found', async () => {
|
||||
const sponsorId = 'sponsor-1';
|
||||
const mockSponsor = { id: sponsorId, name: 'Test Sponsor' };
|
||||
getSponsorUseCase.execute.mockResolvedValue(Result.ok(mockSponsor));
|
||||
it('returns sponsor in presenter when found', async () => {
|
||||
const sponsorId = 's1';
|
||||
const output = { sponsor: { id: sponsorId, name: 'S1' } };
|
||||
getSponsorUseCase.execute.mockResolvedValue(Result.ok(output));
|
||||
|
||||
const result = await service.getSponsor(sponsorId);
|
||||
const presenter = await service.getSponsor(sponsorId);
|
||||
|
||||
expect(result).toEqual(mockSponsor);
|
||||
expect(getSponsorUseCase.execute).toHaveBeenCalledWith({ sponsorId });
|
||||
expect(presenter.viewModel).toEqual({ sponsor: output.sponsor });
|
||||
});
|
||||
|
||||
it('should return null when sponsor not found', async () => {
|
||||
const sponsorId = 'sponsor-1';
|
||||
getSponsorUseCase.execute.mockResolvedValue(Result.err({ code: 'NOT_FOUND' }));
|
||||
it('returns null in presenter when not found', async () => {
|
||||
const sponsorId = 's1';
|
||||
getSponsorUseCase.execute.mockResolvedValue(Result.ok(null));
|
||||
|
||||
const result = await service.getSponsor(sponsorId);
|
||||
const presenter = await service.getSponsor(sponsorId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(presenter.viewModel).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPendingSponsorshipRequests', () => {
|
||||
it('should return pending sponsorship requests', async () => {
|
||||
it('returns requests in presenter on success', async () => {
|
||||
const params = { entityType: 'season' as const, entityId: 'season-1' };
|
||||
const mockResult = {
|
||||
const outputPort = {
|
||||
entityType: 'season',
|
||||
entityId: 'season-1',
|
||||
requests: [],
|
||||
totalCount: 0,
|
||||
};
|
||||
getPendingSponsorshipRequestsUseCase.execute.mockResolvedValue(Result.ok(mockResult));
|
||||
getPendingSponsorshipRequestsUseCase.execute.mockResolvedValue(Result.ok(outputPort));
|
||||
|
||||
const result = await service.getPendingSponsorshipRequests(params);
|
||||
const presenter = await service.getPendingSponsorshipRequests(params);
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(getPendingSponsorshipRequestsUseCase.execute).toHaveBeenCalledWith(params);
|
||||
expect(presenter.viewModel).toEqual(outputPort);
|
||||
});
|
||||
|
||||
it('should return empty result on error', async () => {
|
||||
it('returns empty result on error', async () => {
|
||||
const params = { entityType: 'season' as const, entityId: 'season-1' };
|
||||
getPendingSponsorshipRequestsUseCase.execute.mockResolvedValue(Result.err({ code: 'REPOSITORY_ERROR' }));
|
||||
getPendingSponsorshipRequestsUseCase.execute.mockResolvedValue(
|
||||
Result.err({ code: 'REPOSITORY_ERROR' }),
|
||||
);
|
||||
|
||||
const result = await service.getPendingSponsorshipRequests(params);
|
||||
const presenter = await service.getPendingSponsorshipRequests(params);
|
||||
|
||||
expect(result).toEqual({
|
||||
expect(presenter.viewModel).toEqual({
|
||||
entityType: 'season',
|
||||
entityId: 'season-1',
|
||||
requests: [],
|
||||
@@ -196,63 +254,113 @@ describe('SponsorService', () => {
|
||||
});
|
||||
|
||||
describe('acceptSponsorshipRequest', () => {
|
||||
it('should accept sponsorship request successfully', async () => {
|
||||
const requestId = 'request-1';
|
||||
const respondedBy = 'user-1';
|
||||
const mockResult = {
|
||||
it('returns accept result in presenter on success', async () => {
|
||||
const requestId = 'r1';
|
||||
const respondedBy = 'u1';
|
||||
const outputPort = {
|
||||
requestId,
|
||||
sponsorshipId: 'sponsorship-1',
|
||||
sponsorshipId: 'sp1',
|
||||
status: 'accepted' as const,
|
||||
acceptedAt: new Date(),
|
||||
platformFee: 10,
|
||||
netAmount: 90,
|
||||
};
|
||||
acceptSponsorshipRequestUseCase.execute.mockResolvedValue(Result.ok(mockResult));
|
||||
acceptSponsorshipRequestUseCase.execute.mockResolvedValue(Result.ok(outputPort));
|
||||
|
||||
const result = await service.acceptSponsorshipRequest(requestId, respondedBy);
|
||||
const presenter = await service.acceptSponsorshipRequest(requestId, respondedBy);
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(acceptSponsorshipRequestUseCase.execute).toHaveBeenCalledWith({ requestId, respondedBy });
|
||||
expect(presenter.viewModel).toEqual(outputPort);
|
||||
});
|
||||
|
||||
it('should return null on error', async () => {
|
||||
const requestId = 'request-1';
|
||||
const respondedBy = 'user-1';
|
||||
acceptSponsorshipRequestUseCase.execute.mockResolvedValue(Result.err({ code: 'NOT_FOUND' }));
|
||||
it('returns null in presenter on error', async () => {
|
||||
const requestId = 'r1';
|
||||
const respondedBy = 'u1';
|
||||
acceptSponsorshipRequestUseCase.execute.mockResolvedValue(
|
||||
Result.err({ code: 'SPONSORSHIP_REQUEST_NOT_FOUND' }),
|
||||
);
|
||||
|
||||
const result = await service.acceptSponsorshipRequest(requestId, respondedBy);
|
||||
const presenter = await service.acceptSponsorshipRequest(requestId, respondedBy);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(presenter.viewModel).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rejectSponsorshipRequest', () => {
|
||||
it('should reject sponsorship request successfully', async () => {
|
||||
const requestId = 'request-1';
|
||||
const respondedBy = 'user-1';
|
||||
it('returns reject result in presenter on success', async () => {
|
||||
const requestId = 'r1';
|
||||
const respondedBy = 'u1';
|
||||
const reason = 'Not interested';
|
||||
const mockResult = {
|
||||
const output = {
|
||||
requestId,
|
||||
status: 'rejected' as const,
|
||||
rejectedAt: new Date(),
|
||||
reason,
|
||||
};
|
||||
rejectSponsorshipRequestUseCase.execute.mockResolvedValue(Result.ok(mockResult));
|
||||
rejectSponsorshipRequestUseCase.execute.mockResolvedValue(Result.ok(output));
|
||||
|
||||
const result = await service.rejectSponsorshipRequest(requestId, respondedBy, reason);
|
||||
const presenter = await service.rejectSponsorshipRequest(requestId, respondedBy, reason);
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(rejectSponsorshipRequestUseCase.execute).toHaveBeenCalledWith({ requestId, respondedBy, reason });
|
||||
expect(presenter.viewModel).toEqual(output);
|
||||
});
|
||||
|
||||
it('should return null on error', async () => {
|
||||
const requestId = 'request-1';
|
||||
const respondedBy = 'user-1';
|
||||
rejectSponsorshipRequestUseCase.execute.mockResolvedValue(Result.err({ code: 'NOT_FOUND' }));
|
||||
it('returns null in presenter on error', async () => {
|
||||
const requestId = 'r1';
|
||||
const respondedBy = 'u1';
|
||||
rejectSponsorshipRequestUseCase.execute.mockResolvedValue(
|
||||
Result.err({ code: 'SPONSORSHIP_REQUEST_NOT_FOUND' }),
|
||||
);
|
||||
|
||||
const result = await service.rejectSponsorshipRequest(requestId, respondedBy);
|
||||
const presenter = await service.rejectSponsorshipRequest(requestId, respondedBy);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(presenter.viewModel).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSponsorBilling', () => {
|
||||
it('returns mock billing data in presenter', async () => {
|
||||
const presenter = await service.getSponsorBilling('s1');
|
||||
|
||||
expect(presenter.viewModel).not.toBeNull();
|
||||
expect(presenter.viewModel?.paymentMethods).toBeInstanceOf(Array);
|
||||
expect(presenter.viewModel?.invoices).toBeInstanceOf(Array);
|
||||
expect(presenter.viewModel?.stats).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailableLeagues', () => {
|
||||
it('returns mock leagues in presenter', async () => {
|
||||
const presenter = await service.getAvailableLeagues();
|
||||
|
||||
expect(presenter.viewModel).not.toBeNull();
|
||||
expect(presenter.viewModel?.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLeagueDetail', () => {
|
||||
it('returns league detail in presenter', async () => {
|
||||
const presenter = await service.getLeagueDetail('league-1');
|
||||
|
||||
expect(presenter.viewModel).not.toBeNull();
|
||||
expect(presenter.viewModel?.league.id).toBe('league-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSponsorSettings', () => {
|
||||
it('returns settings in presenter', async () => {
|
||||
const presenter = await service.getSponsorSettings('s1');
|
||||
|
||||
expect(presenter.viewModel).not.toBeNull();
|
||||
expect(presenter.viewModel?.profile).toBeDefined();
|
||||
expect(presenter.viewModel?.notifications).toBeDefined();
|
||||
expect(presenter.viewModel?.privacy).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSponsorSettings', () => {
|
||||
it('returns success result in presenter', async () => {
|
||||
const presenter = await service.updateSponsorSettings('s1', {});
|
||||
|
||||
expect(presenter.viewModel).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { GetEntitySponsorshipPricingResultDTO } from './dtos/GetEntitySponsorshipPricingResultDTO';
|
||||
import { GetSponsorsOutputDTO } from './dtos/GetSponsorsOutputDTO';
|
||||
import { CreateSponsorInputDTO } from './dtos/CreateSponsorInputDTO';
|
||||
import { CreateSponsorOutputDTO } from './dtos/CreateSponsorOutputDTO';
|
||||
import { GetSponsorDashboardQueryParamsDTO } from './dtos/GetSponsorDashboardQueryParamsDTO';
|
||||
import { SponsorDashboardDTO } from './dtos/SponsorDashboardDTO';
|
||||
import { GetSponsorSponsorshipsQueryParamsDTO } from './dtos/GetSponsorSponsorshipsQueryParamsDTO';
|
||||
import { SponsorSponsorshipsDTO } from './dtos/SponsorSponsorshipsDTO';
|
||||
import { GetSponsorOutputDTO } from './dtos/GetSponsorOutputDTO';
|
||||
import { GetPendingSponsorshipRequestsOutputDTO } from './dtos/GetPendingSponsorshipRequestsOutputDTO';
|
||||
import { AcceptSponsorshipRequestInputDTO } from './dtos/AcceptSponsorshipRequestInputDTO';
|
||||
import { RejectSponsorshipRequestInputDTO } from './dtos/RejectSponsorshipRequestInputDTO';
|
||||
import { PaymentMethodDTO } from './dtos/PaymentMethodDTO';
|
||||
@@ -29,139 +22,253 @@ import { CreateSponsorUseCase } from '@core/racing/application/use-cases/CreateS
|
||||
import { GetSponsorDashboardUseCase } from '@core/racing/application/use-cases/GetSponsorDashboardUseCase';
|
||||
import { GetSponsorSponsorshipsUseCase } from '@core/racing/application/use-cases/GetSponsorSponsorshipsUseCase';
|
||||
import { GetSponsorUseCase } from '@core/racing/application/use-cases/GetSponsorUseCase';
|
||||
import { GetPendingSponsorshipRequestsUseCase, GetPendingSponsorshipRequestsDTO } from '@core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
|
||||
import {
|
||||
GetPendingSponsorshipRequestsUseCase,
|
||||
GetPendingSponsorshipRequestsDTO,
|
||||
} from '@core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
|
||||
import { AcceptSponsorshipRequestUseCase } from '@core/racing/application/use-cases/AcceptSponsorshipRequestUseCase';
|
||||
import { RejectSponsorshipRequestUseCase } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
|
||||
import type { SponsorableEntityType } from '@core/racing/domain/entities/SponsorshipRequest';
|
||||
import type { AcceptSponsorshipRequestResultPort } from '@core/racing/application/ports/output/AcceptSponsorshipRequestResultPort';
|
||||
import type { RejectSponsorshipRequestResultDTO } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
|
||||
// Presenters
|
||||
import { GetEntitySponsorshipPricingPresenter } from './presenters/GetEntitySponsorshipPricingPresenter';
|
||||
import { GetSponsorsPresenter } from './presenters/GetSponsorsPresenter';
|
||||
import { CreateSponsorPresenter } from './presenters/CreateSponsorPresenter';
|
||||
import { GetSponsorDashboardPresenter } from './presenters/GetSponsorDashboardPresenter';
|
||||
import { GetSponsorSponsorshipsPresenter } from './presenters/GetSponsorSponsorshipsPresenter';
|
||||
import { GetSponsorPresenter } from './presenters/GetSponsorPresenter';
|
||||
import { GetPendingSponsorshipRequestsPresenter } from './presenters/GetPendingSponsorshipRequestsPresenter';
|
||||
import { AcceptSponsorshipRequestPresenter } from './presenters/AcceptSponsorshipRequestPresenter';
|
||||
import { RejectSponsorshipRequestPresenter } from './presenters/RejectSponsorshipRequestPresenter';
|
||||
import { SponsorBillingPresenter } from './presenters/SponsorBillingPresenter';
|
||||
import { AvailableLeaguesPresenter } from './presenters/AvailableLeaguesPresenter';
|
||||
import { LeagueDetailPresenter } from './presenters/LeagueDetailPresenter';
|
||||
import { SponsorSettingsPresenter } from './presenters/SponsorSettingsPresenter';
|
||||
import { SponsorSettingsUpdatePresenter } from './presenters/SponsorSettingsUpdatePresenter';
|
||||
|
||||
// Tokens
|
||||
import { GET_SPONSORSHIP_PRICING_USE_CASE_TOKEN, GET_SPONSORS_USE_CASE_TOKEN, CREATE_SPONSOR_USE_CASE_TOKEN, GET_SPONSOR_DASHBOARD_USE_CASE_TOKEN, GET_SPONSOR_SPONSORSHIPS_USE_CASE_TOKEN, GET_SPONSOR_USE_CASE_TOKEN, GET_PENDING_SPONSORSHIP_REQUESTS_USE_CASE_TOKEN, ACCEPT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN, REJECT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN, LOGGER_TOKEN } from './SponsorProviders';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import {
|
||||
GET_SPONSORSHIP_PRICING_USE_CASE_TOKEN,
|
||||
GET_SPONSORS_USE_CASE_TOKEN,
|
||||
CREATE_SPONSOR_USE_CASE_TOKEN,
|
||||
GET_SPONSOR_DASHBOARD_USE_CASE_TOKEN,
|
||||
GET_SPONSOR_SPONSORSHIPS_USE_CASE_TOKEN,
|
||||
GET_SPONSOR_USE_CASE_TOKEN,
|
||||
GET_PENDING_SPONSORSHIP_REQUESTS_USE_CASE_TOKEN,
|
||||
ACCEPT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN,
|
||||
REJECT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN,
|
||||
LOGGER_TOKEN,
|
||||
} from './SponsorProviders';
|
||||
|
||||
@Injectable()
|
||||
export class SponsorService {
|
||||
constructor(
|
||||
@Inject(GET_SPONSORSHIP_PRICING_USE_CASE_TOKEN) private readonly getSponsorshipPricingUseCase: GetSponsorshipPricingUseCase,
|
||||
@Inject(GET_SPONSORS_USE_CASE_TOKEN) private readonly getSponsorsUseCase: GetSponsorsUseCase,
|
||||
@Inject(CREATE_SPONSOR_USE_CASE_TOKEN) private readonly createSponsorUseCase: CreateSponsorUseCase,
|
||||
@Inject(GET_SPONSOR_DASHBOARD_USE_CASE_TOKEN) private readonly getSponsorDashboardUseCase: GetSponsorDashboardUseCase,
|
||||
@Inject(GET_SPONSOR_SPONSORSHIPS_USE_CASE_TOKEN) private readonly getSponsorSponsorshipsUseCase: GetSponsorSponsorshipsUseCase,
|
||||
@Inject(GET_SPONSOR_USE_CASE_TOKEN) private readonly getSponsorUseCase: GetSponsorUseCase,
|
||||
@Inject(GET_PENDING_SPONSORSHIP_REQUESTS_USE_CASE_TOKEN) private readonly getPendingSponsorshipRequestsUseCase: GetPendingSponsorshipRequestsUseCase,
|
||||
@Inject(ACCEPT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN) private readonly acceptSponsorshipRequestUseCase: AcceptSponsorshipRequestUseCase,
|
||||
@Inject(REJECT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN) private readonly rejectSponsorshipRequestUseCase: RejectSponsorshipRequestUseCase,
|
||||
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
|
||||
@Inject(GET_SPONSORSHIP_PRICING_USE_CASE_TOKEN)
|
||||
private readonly getSponsorshipPricingUseCase: GetSponsorshipPricingUseCase,
|
||||
@Inject(GET_SPONSORS_USE_CASE_TOKEN)
|
||||
private readonly getSponsorsUseCase: GetSponsorsUseCase,
|
||||
@Inject(CREATE_SPONSOR_USE_CASE_TOKEN)
|
||||
private readonly createSponsorUseCase: CreateSponsorUseCase,
|
||||
@Inject(GET_SPONSOR_DASHBOARD_USE_CASE_TOKEN)
|
||||
private readonly getSponsorDashboardUseCase: GetSponsorDashboardUseCase,
|
||||
@Inject(GET_SPONSOR_SPONSORSHIPS_USE_CASE_TOKEN)
|
||||
private readonly getSponsorSponsorshipsUseCase: GetSponsorSponsorshipsUseCase,
|
||||
@Inject(GET_SPONSOR_USE_CASE_TOKEN)
|
||||
private readonly getSponsorUseCase: GetSponsorUseCase,
|
||||
@Inject(GET_PENDING_SPONSORSHIP_REQUESTS_USE_CASE_TOKEN)
|
||||
private readonly getPendingSponsorshipRequestsUseCase: GetPendingSponsorshipRequestsUseCase,
|
||||
@Inject(ACCEPT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN)
|
||||
private readonly acceptSponsorshipRequestUseCase: AcceptSponsorshipRequestUseCase,
|
||||
@Inject(REJECT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN)
|
||||
private readonly rejectSponsorshipRequestUseCase: RejectSponsorshipRequestUseCase,
|
||||
@Inject(LOGGER_TOKEN)
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async getEntitySponsorshipPricing(): Promise<GetEntitySponsorshipPricingResultDTO> {
|
||||
async getEntitySponsorshipPricing(): Promise<GetEntitySponsorshipPricingPresenter> {
|
||||
this.logger.debug('[SponsorService] Fetching sponsorship pricing.');
|
||||
|
||||
const presenter = new GetEntitySponsorshipPricingPresenter();
|
||||
const result = await this.getSponsorshipPricingUseCase.execute();
|
||||
|
||||
if (result.isErr()) {
|
||||
this.logger.error('[SponsorService] Failed to fetch sponsorship pricing.', result.error);
|
||||
return { entityType: 'season', entityId: '', pricing: [] };
|
||||
presenter.present(null);
|
||||
return presenter;
|
||||
}
|
||||
return result.value as GetEntitySponsorshipPricingResultDTO;
|
||||
|
||||
presenter.present(result.value);
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getSponsors(): Promise<GetSponsorsOutputDTO> {
|
||||
async getSponsors(): Promise<GetSponsorsPresenter> {
|
||||
this.logger.debug('[SponsorService] Fetching sponsors.');
|
||||
|
||||
const presenter = new GetSponsorsPresenter();
|
||||
const result = await this.getSponsorsUseCase.execute();
|
||||
|
||||
if (result.isErr()) {
|
||||
this.logger.error('[SponsorService] Failed to fetch sponsors.', result.error);
|
||||
return { sponsors: [] };
|
||||
presenter.present({ sponsors: [] });
|
||||
return presenter;
|
||||
}
|
||||
return result.value as GetSponsorsOutputDTO;
|
||||
|
||||
presenter.present(result.value);
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async createSponsor(input: CreateSponsorInputDTO): Promise<CreateSponsorOutputDTO> {
|
||||
async createSponsor(input: CreateSponsorInputDTO): Promise<CreateSponsorPresenter> {
|
||||
this.logger.debug('[SponsorService] Creating sponsor.', { input });
|
||||
|
||||
const presenter = new CreateSponsorPresenter();
|
||||
const result = await this.createSponsorUseCase.execute(input);
|
||||
|
||||
if (result.isErr()) {
|
||||
this.logger.error('[SponsorService] Failed to create sponsor.', result.error);
|
||||
throw new Error(result.error.details?.message || 'Failed to create sponsor');
|
||||
}
|
||||
return result.value as CreateSponsorOutputDTO;
|
||||
|
||||
presenter.present(result.value);
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getSponsorDashboard(params: GetSponsorDashboardQueryParamsDTO): Promise<SponsorDashboardDTO | null> {
|
||||
async getSponsorDashboard(
|
||||
params: GetSponsorDashboardQueryParamsDTO,
|
||||
): Promise<GetSponsorDashboardPresenter> {
|
||||
this.logger.debug('[SponsorService] Fetching sponsor dashboard.', { params });
|
||||
|
||||
const presenter = new GetSponsorDashboardPresenter();
|
||||
const result = await this.getSponsorDashboardUseCase.execute(params);
|
||||
|
||||
if (result.isErr()) {
|
||||
this.logger.error('[SponsorService] Failed to fetch sponsor dashboard.', result.error);
|
||||
return null;
|
||||
presenter.present(null);
|
||||
return presenter;
|
||||
}
|
||||
return result.value as SponsorDashboardDTO | null;
|
||||
|
||||
presenter.present(result.value);
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getSponsorSponsorships(params: GetSponsorSponsorshipsQueryParamsDTO): Promise<SponsorSponsorshipsDTO | null> {
|
||||
async getSponsorSponsorships(
|
||||
params: GetSponsorSponsorshipsQueryParamsDTO,
|
||||
): Promise<GetSponsorSponsorshipsPresenter> {
|
||||
this.logger.debug('[SponsorService] Fetching sponsor sponsorships.', { params });
|
||||
|
||||
const presenter = new GetSponsorSponsorshipsPresenter();
|
||||
const result = await this.getSponsorSponsorshipsUseCase.execute(params);
|
||||
|
||||
if (result.isErr()) {
|
||||
this.logger.error('[SponsorService] Failed to fetch sponsor sponsorships.', result.error);
|
||||
return null;
|
||||
presenter.present(null);
|
||||
return presenter;
|
||||
}
|
||||
return result.value as SponsorSponsorshipsDTO | null;
|
||||
|
||||
presenter.present(result.value);
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getSponsor(sponsorId: string): Promise<GetSponsorOutputDTO | null> {
|
||||
async getSponsor(sponsorId: string): Promise<GetSponsorPresenter> {
|
||||
this.logger.debug('[SponsorService] Fetching sponsor.', { sponsorId });
|
||||
|
||||
const presenter = new GetSponsorPresenter();
|
||||
const result = await this.getSponsorUseCase.execute({ sponsorId });
|
||||
|
||||
if (result.isErr()) {
|
||||
this.logger.error('[SponsorService] Failed to fetch sponsor.', result.error);
|
||||
return null;
|
||||
presenter.present(null);
|
||||
return presenter;
|
||||
}
|
||||
return result.value as GetSponsorOutputDTO | null;
|
||||
|
||||
presenter.present(result.value);
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getPendingSponsorshipRequests(params: { entityType: SponsorableEntityType; entityId: string }): Promise<GetPendingSponsorshipRequestsOutputDTO> {
|
||||
async getPendingSponsorshipRequests(params: {
|
||||
entityType: SponsorableEntityType;
|
||||
entityId: string;
|
||||
}): Promise<GetPendingSponsorshipRequestsPresenter> {
|
||||
this.logger.debug('[SponsorService] Fetching pending sponsorship requests.', { params });
|
||||
|
||||
const result = await this.getPendingSponsorshipRequestsUseCase.execute(params as GetPendingSponsorshipRequestsDTO);
|
||||
const presenter = new GetPendingSponsorshipRequestsPresenter();
|
||||
const result = await this.getPendingSponsorshipRequestsUseCase.execute(
|
||||
params as GetPendingSponsorshipRequestsDTO,
|
||||
);
|
||||
|
||||
if (result.isErr()) {
|
||||
this.logger.error('[SponsorService] Failed to fetch pending sponsorship requests.', result.error);
|
||||
return { entityType: params.entityType, entityId: params.entityId, requests: [], totalCount: 0 };
|
||||
presenter.present({
|
||||
entityType: params.entityType,
|
||||
entityId: params.entityId,
|
||||
requests: [],
|
||||
totalCount: 0,
|
||||
});
|
||||
return presenter;
|
||||
}
|
||||
return result.value as GetPendingSponsorshipRequestsOutputDTO;
|
||||
|
||||
presenter.present(result.value);
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async acceptSponsorshipRequest(requestId: string, respondedBy: string): Promise<AcceptSponsorshipRequestResultPort | null> {
|
||||
this.logger.debug('[SponsorService] Accepting sponsorship request.', { requestId, respondedBy });
|
||||
async acceptSponsorshipRequest(
|
||||
requestId: string,
|
||||
respondedBy: string,
|
||||
): Promise<AcceptSponsorshipRequestPresenter> {
|
||||
this.logger.debug('[SponsorService] Accepting sponsorship request.', {
|
||||
requestId,
|
||||
respondedBy,
|
||||
});
|
||||
|
||||
const presenter = new AcceptSponsorshipRequestPresenter();
|
||||
const result = await this.acceptSponsorshipRequestUseCase.execute({
|
||||
requestId,
|
||||
respondedBy,
|
||||
} as AcceptSponsorshipRequestInputDTO);
|
||||
|
||||
const result = await this.acceptSponsorshipRequestUseCase.execute({ requestId, respondedBy });
|
||||
if (result.isErr()) {
|
||||
this.logger.error('[SponsorService] Failed to accept sponsorship request.', result.error);
|
||||
return null;
|
||||
presenter.present(null);
|
||||
return presenter;
|
||||
}
|
||||
return result.value;
|
||||
|
||||
presenter.present(result.value);
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async rejectSponsorshipRequest(requestId: string, respondedBy: string, reason?: string): Promise<RejectSponsorshipRequestResultDTO | null> {
|
||||
this.logger.debug('[SponsorService] Rejecting sponsorship request.', { requestId, respondedBy, reason });
|
||||
async rejectSponsorshipRequest(
|
||||
requestId: string,
|
||||
respondedBy: string,
|
||||
reason?: string,
|
||||
): Promise<RejectSponsorshipRequestPresenter> {
|
||||
this.logger.debug('[SponsorService] Rejecting sponsorship request.', {
|
||||
requestId,
|
||||
respondedBy,
|
||||
reason,
|
||||
});
|
||||
|
||||
const presenter = new RejectSponsorshipRequestPresenter();
|
||||
const result = await this.rejectSponsorshipRequestUseCase.execute({
|
||||
requestId,
|
||||
respondedBy,
|
||||
reason,
|
||||
} as RejectSponsorshipRequestInputDTO);
|
||||
|
||||
const result = await this.rejectSponsorshipRequestUseCase.execute({ requestId, respondedBy, reason });
|
||||
if (result.isErr()) {
|
||||
this.logger.error('[SponsorService] Failed to reject sponsorship request.', result.error);
|
||||
return null;
|
||||
presenter.present(null);
|
||||
return presenter;
|
||||
}
|
||||
return result.value;
|
||||
|
||||
presenter.present(result.value);
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getSponsorBilling(sponsorId: string): Promise<{
|
||||
paymentMethods: PaymentMethodDTO[];
|
||||
invoices: InvoiceDTO[];
|
||||
stats: BillingStatsDTO;
|
||||
}> {
|
||||
async getSponsorBilling(sponsorId: string): Promise<SponsorBillingPresenter> {
|
||||
this.logger.debug('[SponsorService] Fetching sponsor billing.', { sponsorId });
|
||||
|
||||
const presenter = new SponsorBillingPresenter();
|
||||
|
||||
// Mock data - in real implementation, this would come from repositories
|
||||
const paymentMethods: PaymentMethodDTO[] = [
|
||||
{
|
||||
@@ -242,14 +349,16 @@ export class SponsorService {
|
||||
averageMonthlySpend: 2075,
|
||||
};
|
||||
|
||||
return { paymentMethods, invoices, stats };
|
||||
presenter.present({ paymentMethods, invoices, stats });
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getAvailableLeagues(): Promise<AvailableLeagueDTO[]> {
|
||||
async getAvailableLeagues(): Promise<AvailableLeaguesPresenter> {
|
||||
this.logger.debug('[SponsorService] Fetching available leagues.');
|
||||
|
||||
// Mock data
|
||||
return [
|
||||
const presenter = new AvailableLeaguesPresenter();
|
||||
|
||||
const leagues: AvailableLeagueDTO[] = [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'GT3 Masters Championship',
|
||||
@@ -262,7 +371,8 @@ export class SponsorService {
|
||||
tier: 'premium',
|
||||
nextRace: '2025-12-20',
|
||||
seasonStatus: 'active',
|
||||
description: 'Premier GT3 racing with top-tier drivers. Weekly broadcasts and active community.',
|
||||
description:
|
||||
'Premier GT3 racing with top-tier drivers. Weekly broadcasts and active community.',
|
||||
},
|
||||
{
|
||||
id: 'league-2',
|
||||
@@ -276,18 +386,20 @@ export class SponsorService {
|
||||
tier: 'premium',
|
||||
nextRace: '2026-01-05',
|
||||
seasonStatus: 'active',
|
||||
description: 'Multi-class endurance racing. High engagement from dedicated endurance fans.',
|
||||
description:
|
||||
'Multi-class endurance racing. High engagement from dedicated endurance fans.',
|
||||
},
|
||||
];
|
||||
|
||||
presenter.present(leagues);
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getLeagueDetail(leagueId: string): Promise<{
|
||||
league: LeagueDetailDTO;
|
||||
drivers: DriverDTO[];
|
||||
races: RaceDTO[];
|
||||
}> {
|
||||
async getLeagueDetail(leagueId: string): Promise<LeagueDetailPresenter> {
|
||||
this.logger.debug('[SponsorService] Fetching league detail.', { leagueId });
|
||||
|
||||
const presenter = new LeagueDetailPresenter();
|
||||
|
||||
// Mock data
|
||||
const league: LeagueDetailDTO = {
|
||||
id: leagueId,
|
||||
@@ -295,7 +407,8 @@ export class SponsorService {
|
||||
game: 'iRacing',
|
||||
tier: 'premium',
|
||||
season: 'Season 3',
|
||||
description: 'Premier GT3 racing with top-tier drivers competing across the world\'s most iconic circuits.',
|
||||
description:
|
||||
"Premier GT3 racing with top-tier drivers competing across the world's most iconic circuits.",
|
||||
drivers: 48,
|
||||
races: 12,
|
||||
completedRaces: 8,
|
||||
@@ -316,7 +429,7 @@ export class SponsorService {
|
||||
'Race results page branding',
|
||||
'Social media feature posts',
|
||||
'Newsletter sponsor spot',
|
||||
]
|
||||
],
|
||||
},
|
||||
secondary: {
|
||||
available: 1,
|
||||
@@ -327,31 +440,58 @@ export class SponsorService {
|
||||
'League page sidebar placement',
|
||||
'Race results mention',
|
||||
'Social media mentions',
|
||||
]
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const drivers: DriverDTO[] = [
|
||||
{ id: 'd1', name: 'Max Verstappen', country: 'NL', position: 1, races: 8, impressions: 4200, team: 'Red Bull Racing' },
|
||||
{ id: 'd2', name: 'Lewis Hamilton', country: 'GB', position: 2, races: 8, impressions: 3980, team: 'Mercedes AMG' },
|
||||
{
|
||||
id: 'd1',
|
||||
name: 'Max Verstappen',
|
||||
country: 'NL',
|
||||
position: 1,
|
||||
races: 8,
|
||||
impressions: 4200,
|
||||
team: 'Red Bull Racing',
|
||||
},
|
||||
{
|
||||
id: 'd2',
|
||||
name: 'Lewis Hamilton',
|
||||
country: 'GB',
|
||||
position: 2,
|
||||
races: 8,
|
||||
impressions: 3980,
|
||||
team: 'Mercedes AMG',
|
||||
},
|
||||
];
|
||||
|
||||
const races: RaceDTO[] = [
|
||||
{ id: 'r1', name: 'Spa-Francorchamps', date: '2025-12-20', views: 0, status: 'upcoming' },
|
||||
{ id: 'r2', name: 'Monza', date: '2025-12-08', views: 5800, status: 'completed' },
|
||||
{
|
||||
id: 'r1',
|
||||
name: 'Spa-Francorchamps',
|
||||
date: '2025-12-20',
|
||||
views: 0,
|
||||
status: 'upcoming',
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
name: 'Monza',
|
||||
date: '2025-12-08',
|
||||
views: 5800,
|
||||
status: 'completed',
|
||||
},
|
||||
];
|
||||
|
||||
return { league, drivers, races };
|
||||
presenter.present({ league, drivers, races });
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async getSponsorSettings(sponsorId: string): Promise<{
|
||||
profile: SponsorProfileDTO;
|
||||
notifications: NotificationSettingsDTO;
|
||||
privacy: PrivacySettingsDTO;
|
||||
}> {
|
||||
async getSponsorSettings(sponsorId: string): Promise<SponsorSettingsPresenter> {
|
||||
this.logger.debug('[SponsorService] Fetching sponsor settings.', { sponsorId });
|
||||
|
||||
const presenter = new SponsorSettingsPresenter();
|
||||
|
||||
// Mock data
|
||||
const profile: SponsorProfileDTO = {
|
||||
companyName: 'Acme Racing Co.',
|
||||
@@ -359,7 +499,8 @@ export class SponsorService {
|
||||
contactEmail: 'sponsor@acme-racing.com',
|
||||
contactPhone: '+1 (555) 123-4567',
|
||||
website: 'https://acme-racing.com',
|
||||
description: 'Premium sim racing equipment and accessories for competitive drivers.',
|
||||
description:
|
||||
'Premium sim racing equipment and accessories for competitive drivers.',
|
||||
logoUrl: null,
|
||||
industry: 'Racing Equipment',
|
||||
address: {
|
||||
@@ -392,7 +533,8 @@ export class SponsorService {
|
||||
allowDirectContact: true,
|
||||
};
|
||||
|
||||
return { profile, notifications, privacy };
|
||||
presenter.present({ profile, notifications, privacy });
|
||||
return presenter;
|
||||
}
|
||||
|
||||
async updateSponsorSettings(
|
||||
@@ -401,12 +543,15 @@ export class SponsorService {
|
||||
profile?: Partial<SponsorProfileDTO>;
|
||||
notifications?: Partial<NotificationSettingsDTO>;
|
||||
privacy?: Partial<PrivacySettingsDTO>;
|
||||
}
|
||||
): Promise<void> {
|
||||
},
|
||||
): Promise<SponsorSettingsUpdatePresenter> {
|
||||
this.logger.debug('[SponsorService] Updating sponsor settings.', { sponsorId, input });
|
||||
|
||||
// Mock implementation - in real app, this would persist to database
|
||||
// For now, just log the update
|
||||
this.logger.info('[SponsorService] Settings updated successfully.', { sponsorId });
|
||||
|
||||
const presenter = new SponsorSettingsUpdatePresenter();
|
||||
presenter.present({ success: true });
|
||||
return presenter;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GetEntitySponsorshipPricingOutputPort } from '@core/racing/application/ports/output/GetEntitySponsorshipPricingOutputPort';
|
||||
import type { GetSponsorshipPricingOutputPort } from '@core/racing/application/ports/output/GetSponsorshipPricingOutputPort';
|
||||
import { GetEntitySponsorshipPricingResultDTO } from '../dtos/GetEntitySponsorshipPricingResultDTO';
|
||||
|
||||
export class GetEntitySponsorshipPricingPresenter {
|
||||
@@ -8,34 +8,34 @@ export class GetEntitySponsorshipPricingPresenter {
|
||||
this.result = null;
|
||||
}
|
||||
|
||||
async present(output: GetEntitySponsorshipPricingOutputPort | null) {
|
||||
present(output: GetSponsorshipPricingOutputPort | null) {
|
||||
if (!output) {
|
||||
this.result = { pricing: [] };
|
||||
this.result = {
|
||||
entityType: 'season',
|
||||
entityId: '',
|
||||
pricing: [],
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const pricing = [];
|
||||
if (output.mainSlot) {
|
||||
pricing.push({
|
||||
id: `${output.entityType}-${output.entityId}-main`,
|
||||
level: 'main',
|
||||
price: output.mainSlot.price,
|
||||
currency: output.mainSlot.currency,
|
||||
});
|
||||
}
|
||||
if (output.secondarySlot) {
|
||||
pricing.push({
|
||||
id: `${output.entityType}-${output.entityId}-secondary`,
|
||||
level: 'secondary',
|
||||
price: output.secondarySlot.price,
|
||||
currency: output.secondarySlot.currency,
|
||||
});
|
||||
}
|
||||
|
||||
this.result = { pricing };
|
||||
this.result = {
|
||||
entityType: output.entityType,
|
||||
entityId: output.entityId,
|
||||
pricing: output.pricing.map(item => ({
|
||||
id: item.id,
|
||||
level: item.level,
|
||||
price: item.price,
|
||||
currency: item.currency,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
getViewModel(): GetEntitySponsorshipPricingResultDTO | null {
|
||||
return this.result;
|
||||
}
|
||||
}
|
||||
|
||||
get viewModel(): GetEntitySponsorshipPricingResultDTO {
|
||||
if (!this.result) throw new Error('Presenter not presented');
|
||||
return this.result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,31 @@ import type { PendingSponsorshipRequestsOutputPort } from '@core/racing/applicat
|
||||
import { GetPendingSponsorshipRequestsOutputDTO } from '../dtos/GetPendingSponsorshipRequestsOutputDTO';
|
||||
|
||||
export class GetPendingSponsorshipRequestsPresenter {
|
||||
present(outputPort: PendingSponsorshipRequestsOutputPort): GetPendingSponsorshipRequestsOutputDTO {
|
||||
return {
|
||||
private result: GetPendingSponsorshipRequestsOutputDTO | null = null;
|
||||
|
||||
reset() {
|
||||
this.result = null;
|
||||
}
|
||||
|
||||
present(outputPort: PendingSponsorshipRequestsOutputPort | null) {
|
||||
if (!outputPort) {
|
||||
this.result = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.result = {
|
||||
entityType: outputPort.entityType,
|
||||
entityId: outputPort.entityId,
|
||||
requests: outputPort.requests,
|
||||
totalCount: outputPort.totalCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getViewModel(): GetPendingSponsorshipRequestsOutputDTO | null {
|
||||
return this.result;
|
||||
}
|
||||
|
||||
get viewModel(): GetPendingSponsorshipRequestsOutputDTO | null {
|
||||
return this.result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,21 @@ import type { SponsorDashboardOutputPort } from '@core/racing/application/ports/
|
||||
import { SponsorDashboardDTO } from '../dtos/SponsorDashboardDTO';
|
||||
|
||||
export class GetSponsorDashboardPresenter {
|
||||
present(outputPort: SponsorDashboardOutputPort | null): SponsorDashboardDTO | null {
|
||||
return outputPort;
|
||||
private result: SponsorDashboardDTO | null = null;
|
||||
|
||||
reset() {
|
||||
this.result = null;
|
||||
}
|
||||
}
|
||||
|
||||
present(outputPort: SponsorDashboardOutputPort | null) {
|
||||
this.result = outputPort ?? null;
|
||||
}
|
||||
|
||||
getViewModel(): SponsorDashboardDTO | null {
|
||||
return this.result;
|
||||
}
|
||||
|
||||
get viewModel(): SponsorDashboardDTO | null {
|
||||
return this.result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,21 @@ import type { SponsorSponsorshipsOutputPort } from '@core/racing/application/por
|
||||
import { SponsorSponsorshipsDTO } from '../dtos/SponsorSponsorshipsDTO';
|
||||
|
||||
export class GetSponsorSponsorshipsPresenter {
|
||||
present(outputPort: SponsorSponsorshipsOutputPort | null): SponsorSponsorshipsDTO | null {
|
||||
return outputPort;
|
||||
private result: SponsorSponsorshipsDTO | null = null;
|
||||
|
||||
reset() {
|
||||
this.result = null;
|
||||
}
|
||||
}
|
||||
|
||||
present(outputPort: SponsorSponsorshipsOutputPort | null) {
|
||||
this.result = outputPort ?? null;
|
||||
}
|
||||
|
||||
getViewModel(): SponsorSponsorshipsDTO | null {
|
||||
return this.result;
|
||||
}
|
||||
|
||||
get viewModel(): SponsorSponsorshipsDTO | null {
|
||||
return this.result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,24 @@ import type { GetSponsorsOutputPort } from '@core/racing/application/ports/outpu
|
||||
import { GetSponsorsOutputDTO } from '../dtos/GetSponsorsOutputDTO';
|
||||
|
||||
export class GetSponsorsPresenter {
|
||||
present(outputPort: GetSponsorsOutputPort): GetSponsorsOutputDTO {
|
||||
return {
|
||||
private result: GetSponsorsOutputDTO | null = null;
|
||||
|
||||
reset() {
|
||||
this.result = null;
|
||||
}
|
||||
|
||||
present(outputPort: GetSponsorsOutputPort) {
|
||||
this.result = {
|
||||
sponsors: outputPort.sponsors,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getViewModel(): GetSponsorsOutputDTO | null {
|
||||
return this.result;
|
||||
}
|
||||
|
||||
get viewModel(): GetSponsorsOutputDTO {
|
||||
if (!this.result) throw new Error('Presenter not presented');
|
||||
return this.result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user