From e9d6f90bb2bb220a94caff3aecc26f3dadab3d91 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Sat, 20 Dec 2025 17:06:11 +0100 Subject: [PATCH] presenter refactoring --- .../application/hello/hello.service.test.ts | 3 +- .../src/application/hello/hello.service.ts | 10 +- .../hello/presenters/HelloPresenter.ts | 22 ++ .../analytics/AnalyticsController.test.ts | 44 ++- .../domain/analytics/AnalyticsController.ts | 22 +- .../src/domain/analytics/AnalyticsService.ts | 32 +- .../GetAnalyticsMetricsPresenter.test.ts | 45 +++ .../GetAnalyticsMetricsPresenter.ts | 24 ++ .../GetDashboardDataPresenter.test.ts | 45 +++ .../presenters/GetDashboardDataPresenter.ts | 24 ++ .../RecordEngagementPresenter.test.ts | 39 ++ .../presenters/RecordEngagementPresenter.ts | 22 ++ .../RecordPageViewPresenter.test.ts | 36 ++ .../presenters/RecordPageViewPresenter.ts | 21 + apps/api/src/domain/auth/AuthController.ts | 14 +- apps/api/src/domain/auth/AuthService.ts | 44 ++- .../presenters/AuthSessionPresenter.test.ts | 61 +++ .../auth/presenters/AuthSessionPresenter.ts | 31 ++ .../domain/dashboard/DashboardController.ts | 3 +- .../src/domain/dashboard/DashboardService.ts | 8 +- .../DashboardOverviewPresenter.test.ts | 166 ++++++++ .../presenters/DashboardOverviewPresenter.ts | 116 ++++++ .../domain/driver/DriverController.test.ts | 20 +- .../api/src/domain/driver/DriverController.ts | 29 +- apps/api/src/domain/driver/DriverService.ts | 198 +++++----- .../presenters/CompleteOnboardingPresenter.ts | 29 ++ .../driver/presenters/DriverPresenter.ts | 30 ++ .../presenters/DriverProfilePresenter.ts | 47 +++ .../DriverRegistrationStatusPresenter.ts | 25 ++ .../presenters/DriversLeaderboardPresenter.ts | 58 +-- apps/api/src/domain/league/LeagueService.ts | 140 ++++--- .../league/dtos/LeagueJoinRequestDTO.ts | 15 +- .../dtos/RemoveLeagueMemberOutputDTO.ts | 7 +- .../dtos/UpdateLeagueMemberRoleOutputDTO.ts | 7 +- .../ApproveLeagueJoinRequestPresenter.ts | 16 +- .../GetLeagueAdminPermissionsPresenter.ts | 22 ++ .../GetLeagueMembershipsPresenter.test.ts | 28 ++ .../GetLeagueMembershipsPresenter.ts | 32 ++ .../GetLeagueOwnerSummaryPresenter.ts | 43 +- .../presenters/GetLeagueProtestsPresenter.ts | 94 +++-- .../presenters/GetLeagueSeasonsPresenter.ts | 32 +- .../GetSeasonSponsorshipsPresenter.ts | 20 + .../league/presenters/JoinLeaguePresenter.ts | 22 +- .../LeagueJoinRequestsPresenter.test.ts | 28 ++ .../presenters/LeagueJoinRequestsPresenter.ts | 32 ++ .../LeagueOwnerSummaryPresenter.test.ts | 42 ++ .../presenters/LeagueOwnerSummaryPresenter.ts | 33 ++ .../presenters/LeagueSchedulePresenter.ts | 50 ++- .../RejectLeagueJoinRequestPresenter.ts | 22 +- .../presenters/RemoveLeagueMemberPresenter.ts | 20 +- .../TransferLeagueOwnershipPresenter.ts | 20 +- .../UpdateLeagueMemberRolePresenter.ts | 20 +- .../src/domain/media/MediaController.test.ts | 32 +- apps/api/src/domain/media/MediaController.ts | 52 ++- apps/api/src/domain/media/MediaService.ts | 96 +---- .../media/presenters/DeleteMediaPresenter.ts | 11 +- .../media/presenters/GetAvatarPresenter.ts | 14 +- .../media/presenters/GetMediaPresenter.ts | 23 +- .../media/presenters/UpdateAvatarPresenter.ts | 15 +- .../media/presenters/UploadMediaPresenter.ts | 19 +- .../payments/PaymentsController.test.ts | 26 +- .../src/domain/payments/PaymentsController.ts | 36 +- .../src/domain/payments/PaymentsService.ts | 48 +-- .../protests/ProtestsController.test.ts | 102 ++++- .../src/domain/protests/ProtestsController.ts | 26 +- .../domain/protests/ProtestsService.test.ts | 101 +++++ .../src/domain/protests/ProtestsService.ts | 39 +- .../presenters/ReviewProtestPresenter.ts | 45 +++ .../src/domain/race/RaceController.test.ts | 22 +- apps/api/src/domain/race/RaceController.ts | 95 ++++- apps/api/src/domain/race/RaceProviders.ts | 1 - apps/api/src/domain/race/RaceService.test.ts | 168 ++++++++ apps/api/src/domain/race/RaceService.ts | 371 +++++++----------- .../domain/race/dtos/DashboardOverviewDTO.ts | 2 +- .../domain/race/dtos/FileProtestCommandDTO.ts | 2 +- .../domain/race/dtos/RaceDetailEntryDTO.ts | 2 +- .../presenters/AllRacesPageDataPresenter.ts | 21 + .../race/presenters/CommandResultPresenter.ts | 36 ++ .../race/presenters/RaceDetailPresenter.ts | 107 +++++ .../race/presenters/RacePenaltiesPresenter.ts | 42 ++ .../race/presenters/RaceProtestsPresenter.ts | 43 ++ .../presenters/RaceResultsDetailPresenter.ts | 56 +++ .../race/presenters/RaceWithSOFPresenter.ts | 26 ++ .../race/presenters/RacesPageDataPresenter.ts | 43 ++ .../domain/sponsor/SponsorController.test.ts | 196 +++++++-- .../src/domain/sponsor/SponsorController.ts | 257 ++++++++---- .../src/domain/sponsor/SponsorService.test.ts | 324 ++++++++++----- apps/api/src/domain/sponsor/SponsorService.ts | 319 +++++++++++---- .../AcceptSponsorshipRequestPresenter.ts | 42 ++ .../presenters/AvailableLeaguesPresenter.ts | 21 + .../GetEntitySponsorshipPricingPresenter.ts | 46 +-- .../GetPendingSponsorshipRequestsPresenter.ts | 25 +- .../GetSponsorDashboardPresenter.ts | 20 +- .../sponsor/presenters/GetSponsorPresenter.ts | 42 ++ .../GetSponsorSponsorshipsPresenter.ts | 20 +- .../presenters/GetSponsorsPresenter.ts | 21 +- .../presenters/LeagueDetailPresenter.ts | 29 ++ .../RejectSponsorshipRequestPresenter.ts | 21 + .../presenters/SponsorBillingPresenter.ts | 30 ++ .../presenters/SponsorSettingsPresenter.ts | 29 ++ .../SponsorSettingsUpdatePresenter.ts | 25 ++ apps/api/src/domain/team/TeamController.ts | 26 +- apps/api/src/domain/team/TeamService.test.ts | 1 - apps/api/src/domain/team/TeamService.ts | 104 +++-- .../team/presenters/CreateTeamPresenter.ts | 36 ++ .../presenters/TeamMembershipPresenter.ts | 30 ++ .../team/presenters/UpdateTeamPresenter.ts | 33 ++ apps/api/src/presentation/hello.controller.ts | 8 +- apps/website/next.config.mjs | 2 +- 109 files changed, 4159 insertions(+), 1283 deletions(-) create mode 100644 apps/api/src/application/hello/presenters/HelloPresenter.ts create mode 100644 apps/api/src/domain/analytics/presenters/GetAnalyticsMetricsPresenter.test.ts create mode 100644 apps/api/src/domain/analytics/presenters/GetAnalyticsMetricsPresenter.ts create mode 100644 apps/api/src/domain/analytics/presenters/GetDashboardDataPresenter.test.ts create mode 100644 apps/api/src/domain/analytics/presenters/GetDashboardDataPresenter.ts create mode 100644 apps/api/src/domain/analytics/presenters/RecordEngagementPresenter.test.ts create mode 100644 apps/api/src/domain/analytics/presenters/RecordEngagementPresenter.ts create mode 100644 apps/api/src/domain/analytics/presenters/RecordPageViewPresenter.test.ts create mode 100644 apps/api/src/domain/analytics/presenters/RecordPageViewPresenter.ts create mode 100644 apps/api/src/domain/auth/presenters/AuthSessionPresenter.test.ts create mode 100644 apps/api/src/domain/auth/presenters/AuthSessionPresenter.ts create mode 100644 apps/api/src/domain/dashboard/presenters/DashboardOverviewPresenter.test.ts create mode 100644 apps/api/src/domain/dashboard/presenters/DashboardOverviewPresenter.ts create mode 100644 apps/api/src/domain/driver/presenters/CompleteOnboardingPresenter.ts create mode 100644 apps/api/src/domain/driver/presenters/DriverPresenter.ts create mode 100644 apps/api/src/domain/driver/presenters/DriverProfilePresenter.ts create mode 100644 apps/api/src/domain/driver/presenters/DriverRegistrationStatusPresenter.ts create mode 100644 apps/api/src/domain/league/presenters/GetLeagueAdminPermissionsPresenter.ts create mode 100644 apps/api/src/domain/league/presenters/GetLeagueMembershipsPresenter.test.ts create mode 100644 apps/api/src/domain/league/presenters/GetLeagueMembershipsPresenter.ts create mode 100644 apps/api/src/domain/league/presenters/GetSeasonSponsorshipsPresenter.ts create mode 100644 apps/api/src/domain/league/presenters/LeagueJoinRequestsPresenter.test.ts create mode 100644 apps/api/src/domain/league/presenters/LeagueJoinRequestsPresenter.ts create mode 100644 apps/api/src/domain/league/presenters/LeagueOwnerSummaryPresenter.test.ts create mode 100644 apps/api/src/domain/league/presenters/LeagueOwnerSummaryPresenter.ts create mode 100644 apps/api/src/domain/protests/ProtestsService.test.ts create mode 100644 apps/api/src/domain/protests/presenters/ReviewProtestPresenter.ts create mode 100644 apps/api/src/domain/race/RaceService.test.ts create mode 100644 apps/api/src/domain/race/presenters/AllRacesPageDataPresenter.ts create mode 100644 apps/api/src/domain/race/presenters/CommandResultPresenter.ts create mode 100644 apps/api/src/domain/race/presenters/RaceDetailPresenter.ts create mode 100644 apps/api/src/domain/race/presenters/RacePenaltiesPresenter.ts create mode 100644 apps/api/src/domain/race/presenters/RaceProtestsPresenter.ts create mode 100644 apps/api/src/domain/race/presenters/RaceResultsDetailPresenter.ts create mode 100644 apps/api/src/domain/race/presenters/RaceWithSOFPresenter.ts create mode 100644 apps/api/src/domain/race/presenters/RacesPageDataPresenter.ts create mode 100644 apps/api/src/domain/sponsor/presenters/AcceptSponsorshipRequestPresenter.ts create mode 100644 apps/api/src/domain/sponsor/presenters/AvailableLeaguesPresenter.ts create mode 100644 apps/api/src/domain/sponsor/presenters/GetSponsorPresenter.ts create mode 100644 apps/api/src/domain/sponsor/presenters/LeagueDetailPresenter.ts create mode 100644 apps/api/src/domain/sponsor/presenters/RejectSponsorshipRequestPresenter.ts create mode 100644 apps/api/src/domain/sponsor/presenters/SponsorBillingPresenter.ts create mode 100644 apps/api/src/domain/sponsor/presenters/SponsorSettingsPresenter.ts create mode 100644 apps/api/src/domain/sponsor/presenters/SponsorSettingsUpdatePresenter.ts create mode 100644 apps/api/src/domain/team/presenters/CreateTeamPresenter.ts create mode 100644 apps/api/src/domain/team/presenters/TeamMembershipPresenter.ts create mode 100644 apps/api/src/domain/team/presenters/UpdateTeamPresenter.ts diff --git a/apps/api/src/application/hello/hello.service.test.ts b/apps/api/src/application/hello/hello.service.test.ts index e50a2c649..038476b8b 100644 --- a/apps/api/src/application/hello/hello.service.test.ts +++ b/apps/api/src/application/hello/hello.service.test.ts @@ -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!' }); }); }); diff --git a/apps/api/src/application/hello/hello.service.ts b/apps/api/src/application/hello/hello.service.ts index 0da054abd..b99f25e07 100644 --- a/apps/api/src/application/hello/hello.service.ts +++ b/apps/api/src/application/hello/hello.service.ts @@ -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; } -} +} \ No newline at end of file diff --git a/apps/api/src/application/hello/presenters/HelloPresenter.ts b/apps/api/src/application/hello/presenters/HelloPresenter.ts new file mode 100644 index 000000000..b2890c063 --- /dev/null +++ b/apps/api/src/application/hello/presenters/HelloPresenter.ts @@ -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; + } +} diff --git a/apps/api/src/domain/analytics/AnalyticsController.test.ts b/apps/api/src/domain/analytics/AnalyticsController.test.ts index 020fdb67b..1c11b942c 100644 --- a/apps/api/src/domain/analytics/AnalyticsController.test.ts +++ b/apps/api/src/domain/analytics/AnalyticsController.test.ts @@ -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> = { 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> = { 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); }); }); }); \ No newline at end of file diff --git a/apps/api/src/domain/analytics/AnalyticsController.ts b/apps/api/src/domain/analytics/AnalyticsController.ts index 0df9d0671..612ef2c21 100644 --- a/apps/api/src/domain/analytics/AnalyticsController.ts +++ b/apps/api/src/domain/analytics/AnalyticsController.ts @@ -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 { - 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 { - 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 { - return await this.analyticsService.getDashboardData(); + async getDashboardData(): Promise { + 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 { - return await this.analyticsService.getAnalyticsMetrics(); + async getAnalyticsMetrics(): Promise { + const presenter = await this.analyticsService.getAnalyticsMetrics(); + return presenter.viewModel; } } diff --git a/apps/api/src/domain/analytics/AnalyticsService.ts b/apps/api/src/domain/analytics/AnalyticsService.ts index 343fdbf54..eb71fbdd4 100644 --- a/apps/api/src/domain/analytics/AnalyticsService.ts +++ b/apps/api/src/domain/analytics/AnalyticsService.ts @@ -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 { - return await this.recordPageViewUseCase.execute(input); + async recordPageView(input: RecordPageViewInput): Promise { + const presenter = new RecordPageViewPresenter(); + await this.recordPageViewUseCase.execute(input, presenter); + return presenter; } - async recordEngagement(input: RecordEngagementInput): Promise { - return await this.recordEngagementUseCase.execute(input); + async recordEngagement(input: RecordEngagementInput): Promise { + const presenter = new RecordEngagementPresenter(); + await this.recordEngagementUseCase.execute(input, presenter); + return presenter; } - async getDashboardData(): Promise { - return await this.getDashboardDataUseCase.execute(); + async getDashboardData(): Promise { + const presenter = new GetDashboardDataPresenter(); + await this.getDashboardDataUseCase.execute(undefined, presenter); + return presenter; } - async getAnalyticsMetrics(): Promise { - return await this.getAnalyticsMetricsUseCase.execute(); + async getAnalyticsMetrics(): Promise { + const presenter = new GetAnalyticsMetricsPresenter(); + await this.getAnalyticsMetricsUseCase.execute(undefined, presenter); + return presenter; } } diff --git a/apps/api/src/domain/analytics/presenters/GetAnalyticsMetricsPresenter.test.ts b/apps/api/src/domain/analytics/presenters/GetAnalyticsMetricsPresenter.test.ts new file mode 100644 index 000000000..7e4a13140 --- /dev/null +++ b/apps/api/src/domain/analytics/presenters/GetAnalyticsMetricsPresenter.test.ts @@ -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'); + }); +}); diff --git a/apps/api/src/domain/analytics/presenters/GetAnalyticsMetricsPresenter.ts b/apps/api/src/domain/analytics/presenters/GetAnalyticsMetricsPresenter.ts new file mode 100644 index 000000000..6afc09400 --- /dev/null +++ b/apps/api/src/domain/analytics/presenters/GetAnalyticsMetricsPresenter.ts @@ -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; + } +} diff --git a/apps/api/src/domain/analytics/presenters/GetDashboardDataPresenter.test.ts b/apps/api/src/domain/analytics/presenters/GetDashboardDataPresenter.test.ts new file mode 100644 index 000000000..bb541444f --- /dev/null +++ b/apps/api/src/domain/analytics/presenters/GetDashboardDataPresenter.test.ts @@ -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'); + }); +}); diff --git a/apps/api/src/domain/analytics/presenters/GetDashboardDataPresenter.ts b/apps/api/src/domain/analytics/presenters/GetDashboardDataPresenter.ts new file mode 100644 index 000000000..2622f964c --- /dev/null +++ b/apps/api/src/domain/analytics/presenters/GetDashboardDataPresenter.ts @@ -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; + } +} diff --git a/apps/api/src/domain/analytics/presenters/RecordEngagementPresenter.test.ts b/apps/api/src/domain/analytics/presenters/RecordEngagementPresenter.test.ts new file mode 100644 index 000000000..2ecc6e9ea --- /dev/null +++ b/apps/api/src/domain/analytics/presenters/RecordEngagementPresenter.test.ts @@ -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'); + }); +}); diff --git a/apps/api/src/domain/analytics/presenters/RecordEngagementPresenter.ts b/apps/api/src/domain/analytics/presenters/RecordEngagementPresenter.ts new file mode 100644 index 000000000..42ce3739f --- /dev/null +++ b/apps/api/src/domain/analytics/presenters/RecordEngagementPresenter.ts @@ -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; + } +} diff --git a/apps/api/src/domain/analytics/presenters/RecordPageViewPresenter.test.ts b/apps/api/src/domain/analytics/presenters/RecordPageViewPresenter.test.ts new file mode 100644 index 000000000..31c4d5ef9 --- /dev/null +++ b/apps/api/src/domain/analytics/presenters/RecordPageViewPresenter.test.ts @@ -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'); + }); +}); diff --git a/apps/api/src/domain/analytics/presenters/RecordPageViewPresenter.ts b/apps/api/src/domain/analytics/presenters/RecordPageViewPresenter.ts new file mode 100644 index 000000000..bdd6c5e6a --- /dev/null +++ b/apps/api/src/domain/analytics/presenters/RecordPageViewPresenter.ts @@ -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; + } +} diff --git a/apps/api/src/domain/auth/AuthController.ts b/apps/api/src/domain/auth/AuthController.ts index a251e3854..25d7d1d66 100644 --- a/apps/api/src/domain/auth/AuthController.ts +++ b/apps/api/src/domain/auth/AuthController.ts @@ -8,22 +8,26 @@ export class AuthController { @Post('signup') async signup(@Body() params: SignupParams): Promise { - return this.authService.signupWithEmail(params); + const presenter = await this.authService.signupWithEmail(params); + return presenter.viewModel; } @Post('login') async login(@Body() params: LoginParams): Promise { - return this.authService.loginWithEmail(params); + const presenter = await this.authService.loginWithEmail(params); + return presenter.viewModel; } @Get('session') async getSession(): Promise { - return this.authService.getCurrentSession(); + const presenter = await this.authService.getCurrentSession(); + return presenter ? presenter.viewModel : null; } @Post('logout') - async logout(): Promise { - return this.authService.logout(); + async logout(): Promise<{ success: boolean }> { + const presenter = await this.authService.logout(); + return presenter.viewModel; } } diff --git a/apps/api/src/domain/auth/AuthService.ts b/apps/api/src/domain/auth/AuthService.ts index cae7232cb..106f9975b 100644 --- a/apps/api/src/domain/auth/AuthService.ts +++ b/apps/api/src/domain/auth/AuthService.ts @@ -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 { + async getCurrentSession(): Promise { 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 { + async signupWithEmail(params: SignupParams): Promise { 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 { + async loginWithEmail(params: LoginParams): Promise { 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 { + async logout(): Promise { this.logger.debug('[AuthService] Attempting logout.'); + const presenter = new CommandResultPresenter(); await this.logoutUseCase.execute(); + presenter.present({ success: true }); + return presenter; } } diff --git a/apps/api/src/domain/auth/presenters/AuthSessionPresenter.test.ts b/apps/api/src/domain/auth/presenters/AuthSessionPresenter.test.ts new file mode 100644 index 000000000..e8ebe6546 --- /dev/null +++ b/apps/api/src/domain/auth/presenters/AuthSessionPresenter.test.ts @@ -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); + }); +}); diff --git a/apps/api/src/domain/auth/presenters/AuthSessionPresenter.ts b/apps/api/src/domain/auth/presenters/AuthSessionPresenter.ts new file mode 100644 index 000000000..a16b3eef5 --- /dev/null +++ b/apps/api/src/domain/auth/presenters/AuthSessionPresenter.ts @@ -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; + } +} diff --git a/apps/api/src/domain/dashboard/DashboardController.ts b/apps/api/src/domain/dashboard/DashboardController.ts index cbc7a2bfc..2b963cd7a 100644 --- a/apps/api/src/domain/dashboard/DashboardController.ts +++ b/apps/api/src/domain/dashboard/DashboardController.ts @@ -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 { - return this.dashboardService.getDashboardOverview(driverId); + const presenter = await this.dashboardService.getDashboardOverview(driverId); + return presenter.viewModel; } } \ No newline at end of file diff --git a/apps/api/src/domain/dashboard/DashboardService.ts b/apps/api/src/domain/dashboard/DashboardService.ts index 52ff30daa..f3ffd0ba7 100644 --- a/apps/api/src/domain/dashboard/DashboardService.ts +++ b/apps/api/src/domain/dashboard/DashboardService.ts @@ -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 { + async getDashboardOverview(driverId: string): Promise { 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; } } \ No newline at end of file diff --git a/apps/api/src/domain/dashboard/presenters/DashboardOverviewPresenter.test.ts b/apps/api/src/domain/dashboard/presenters/DashboardOverviewPresenter.test.ts new file mode 100644 index 000000000..9aee1b6c3 --- /dev/null +++ b/apps/api/src/domain/dashboard/presenters/DashboardOverviewPresenter.test.ts @@ -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); + }); +}); diff --git a/apps/api/src/domain/dashboard/presenters/DashboardOverviewPresenter.ts b/apps/api/src/domain/dashboard/presenters/DashboardOverviewPresenter.ts new file mode 100644 index 000000000..756510959 --- /dev/null +++ b/apps/api/src/domain/dashboard/presenters/DashboardOverviewPresenter.ts @@ -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; + } +} diff --git a/apps/api/src/domain/driver/DriverController.test.ts b/apps/api/src/domain/driver/DriverController.test.ts index 3bd33b4f3..57bf3d522 100644 --- a/apps/api/src/domain/driver/DriverController.test.ts +++ b/apps/api/src/domain/driver/DriverController.test.ts @@ -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 = { 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 = { 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); diff --git a/apps/api/src/domain/driver/DriverController.ts b/apps/api/src/domain/driver/DriverController.ts index ea2900be8..68448af30 100644 --- a/apps/api/src/domain/driver/DriverController.ts +++ b/apps/api/src/domain/driver/DriverController.ts @@ -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 { - 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 { - 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 { - // 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 { - // 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 { - 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 { - 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 { - 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 { - 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 diff --git a/apps/api/src/domain/driver/DriverService.ts b/apps/api/src/domain/driver/DriverService.ts index 87d334c32..78a41aa36 100644 --- a/apps/api/src/domain/driver/DriverService.ts +++ b/apps/api/src/domain/driver/DriverService.ts @@ -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 { + async getDriversLeaderboard(): Promise { 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 { + async getTotalDrivers(): Promise { 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 { + async completeOnboarding(userId: string, input: CompleteOnboardingInputDTO): Promise { 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 { + async getDriverRegistrationStatus( + query: GetDriverRegistrationStatusQueryDTO, + ): Promise { 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 { + async getCurrentDriver(userId: string): Promise { 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 { + async updateDriverProfile( + driverId: string, + bio?: string, + country?: string, + ): Promise { 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 { + async getDriver(driverId: string): Promise { 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 { + async getDriverProfile(driverId: string): Promise { 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; } } diff --git a/apps/api/src/domain/driver/presenters/CompleteOnboardingPresenter.ts b/apps/api/src/domain/driver/presenters/CompleteOnboardingPresenter.ts new file mode 100644 index 000000000..b4ae756e0 --- /dev/null +++ b/apps/api/src/domain/driver/presenters/CompleteOnboardingPresenter.ts @@ -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; + } +} diff --git a/apps/api/src/domain/driver/presenters/DriverPresenter.ts b/apps/api/src/domain/driver/presenters/DriverPresenter.ts new file mode 100644 index 000000000..696f2d556 --- /dev/null +++ b/apps/api/src/domain/driver/presenters/DriverPresenter.ts @@ -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; + } +} diff --git a/apps/api/src/domain/driver/presenters/DriverProfilePresenter.ts b/apps/api/src/domain/driver/presenters/DriverProfilePresenter.ts new file mode 100644 index 000000000..0437982f2 --- /dev/null +++ b/apps/api/src/domain/driver/presenters/DriverProfilePresenter.ts @@ -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; + } +} diff --git a/apps/api/src/domain/driver/presenters/DriverRegistrationStatusPresenter.ts b/apps/api/src/domain/driver/presenters/DriverRegistrationStatusPresenter.ts new file mode 100644 index 000000000..458938353 --- /dev/null +++ b/apps/api/src/domain/driver/presenters/DriverRegistrationStatusPresenter.ts @@ -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; + } +} diff --git a/apps/api/src/domain/driver/presenters/DriversLeaderboardPresenter.ts b/apps/api/src/domain/driver/presenters/DriversLeaderboardPresenter.ts index 7720a24dc..10e67278f 100644 --- a/apps/api/src/domain/driver/presenters/DriversLeaderboardPresenter.ts +++ b/apps/api/src/domain/driver/presenters/DriversLeaderboardPresenter.ts @@ -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, }; } diff --git a/apps/api/src/domain/league/LeagueService.ts b/apps/api/src/domain/league/LeagueService.ts index 8989219f8..8412e0808 100644 --- a/apps/api/src/domain/league/LeagueService.ts +++ b/apps/api/src/domain/league/LeagueService.ts @@ -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 { + async getLeagueJoinRequests(leagueId: string): Promise { 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 { @@ -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 { 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 { + async getLeagueAdminPermissions(query: GetLeagueAdminPermissionsInputDTO): Promise { 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 { 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 { + async getLeagueOwnerSummary(query: GetLeagueOwnerSummaryQueryDTO): Promise { 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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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(); } diff --git a/apps/api/src/domain/league/dtos/LeagueJoinRequestDTO.ts b/apps/api/src/domain/league/dtos/LeagueJoinRequestDTO.ts index 46b97af06..fa9baa3d6 100644 --- a/apps/api/src/domain/league/dtos/LeagueJoinRequestDTO.ts +++ b/apps/api/src/domain/league/dtos/LeagueJoinRequestDTO.ts @@ -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; + }; } \ No newline at end of file diff --git a/apps/api/src/domain/league/dtos/RemoveLeagueMemberOutputDTO.ts b/apps/api/src/domain/league/dtos/RemoveLeagueMemberOutputDTO.ts index c9cca5bee..4a1ad8483 100644 --- a/apps/api/src/domain/league/dtos/RemoveLeagueMemberOutputDTO.ts +++ b/apps/api/src/domain/league/dtos/RemoveLeagueMemberOutputDTO.ts @@ -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; } \ No newline at end of file diff --git a/apps/api/src/domain/league/dtos/UpdateLeagueMemberRoleOutputDTO.ts b/apps/api/src/domain/league/dtos/UpdateLeagueMemberRoleOutputDTO.ts index d2e34ba07..290770c84 100644 --- a/apps/api/src/domain/league/dtos/UpdateLeagueMemberRoleOutputDTO.ts +++ b/apps/api/src/domain/league/dtos/UpdateLeagueMemberRoleOutputDTO.ts @@ -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; } \ No newline at end of file diff --git a/apps/api/src/domain/league/presenters/ApproveLeagueJoinRequestPresenter.ts b/apps/api/src/domain/league/presenters/ApproveLeagueJoinRequestPresenter.ts index 4e09f124f..54bf60ed3 100644 --- a/apps/api/src/domain/league/presenters/ApproveLeagueJoinRequestPresenter.ts +++ b/apps/api/src/domain/league/presenters/ApproveLeagueJoinRequestPresenter.ts @@ -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; + } } \ No newline at end of file diff --git a/apps/api/src/domain/league/presenters/GetLeagueAdminPermissionsPresenter.ts b/apps/api/src/domain/league/presenters/GetLeagueAdminPermissionsPresenter.ts new file mode 100644 index 000000000..e3a0593a5 --- /dev/null +++ b/apps/api/src/domain/league/presenters/GetLeagueAdminPermissionsPresenter.ts @@ -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 { + 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; + } +} diff --git a/apps/api/src/domain/league/presenters/GetLeagueMembershipsPresenter.test.ts b/apps/api/src/domain/league/presenters/GetLeagueMembershipsPresenter.test.ts new file mode 100644 index 000000000..de39f41d6 --- /dev/null +++ b/apps/api/src/domain/league/presenters/GetLeagueMembershipsPresenter.test.ts @@ -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'); + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/league/presenters/GetLeagueMembershipsPresenter.ts b/apps/api/src/domain/league/presenters/GetLeagueMembershipsPresenter.ts new file mode 100644 index 000000000..1a24f9585 --- /dev/null +++ b/apps/api/src/domain/league/presenters/GetLeagueMembershipsPresenter.ts @@ -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; + } +} \ No newline at end of file diff --git a/apps/api/src/domain/league/presenters/GetLeagueOwnerSummaryPresenter.ts b/apps/api/src/domain/league/presenters/GetLeagueOwnerSummaryPresenter.ts index ad7f06a7c..c6e3fb76e 100644 --- a/apps/api/src/domain/league/presenters/GetLeagueOwnerSummaryPresenter.ts +++ b/apps/api/src/domain/league/presenters/GetLeagueOwnerSummaryPresenter.ts @@ -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; + } } \ No newline at end of file diff --git a/apps/api/src/domain/league/presenters/GetLeagueProtestsPresenter.ts b/apps/api/src/domain/league/presenters/GetLeagueProtestsPresenter.ts index 2bd5349d4..5768d52ee 100644 --- a/apps/api/src/domain/league/presenters/GetLeagueProtestsPresenter.ts +++ b/apps/api/src/domain/league/presenters/GetLeagueProtestsPresenter.ts @@ -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, - }; } \ No newline at end of file diff --git a/apps/api/src/domain/league/presenters/GetLeagueSeasonsPresenter.ts b/apps/api/src/domain/league/presenters/GetLeagueSeasonsPresenter.ts index 21d586fc0..cf75262b9 100644 --- a/apps/api/src/domain/league/presenters/GetLeagueSeasonsPresenter.ts +++ b/apps/api/src/domain/league/presenters/GetLeagueSeasonsPresenter.ts @@ -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; + } } \ No newline at end of file diff --git a/apps/api/src/domain/league/presenters/GetSeasonSponsorshipsPresenter.ts b/apps/api/src/domain/league/presenters/GetSeasonSponsorshipsPresenter.ts new file mode 100644 index 000000000..7eab837c4 --- /dev/null +++ b/apps/api/src/domain/league/presenters/GetSeasonSponsorshipsPresenter.ts @@ -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; + } +} \ No newline at end of file diff --git a/apps/api/src/domain/league/presenters/JoinLeaguePresenter.ts b/apps/api/src/domain/league/presenters/JoinLeaguePresenter.ts index 6bd6fe1c6..0a1255380 100644 --- a/apps/api/src/domain/league/presenters/JoinLeaguePresenter.ts +++ b/apps/api/src/domain/league/presenters/JoinLeaguePresenter.ts @@ -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; + } } \ No newline at end of file diff --git a/apps/api/src/domain/league/presenters/LeagueJoinRequestsPresenter.test.ts b/apps/api/src/domain/league/presenters/LeagueJoinRequestsPresenter.test.ts new file mode 100644 index 000000000..9f5c7356b --- /dev/null +++ b/apps/api/src/domain/league/presenters/LeagueJoinRequestsPresenter.test.ts @@ -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'); + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/league/presenters/LeagueJoinRequestsPresenter.ts b/apps/api/src/domain/league/presenters/LeagueJoinRequestsPresenter.ts new file mode 100644 index 000000000..df875b972 --- /dev/null +++ b/apps/api/src/domain/league/presenters/LeagueJoinRequestsPresenter.ts @@ -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; + } +} \ No newline at end of file diff --git a/apps/api/src/domain/league/presenters/LeagueOwnerSummaryPresenter.test.ts b/apps/api/src/domain/league/presenters/LeagueOwnerSummaryPresenter.test.ts new file mode 100644 index 000000000..0a89ad450 --- /dev/null +++ b/apps/api/src/domain/league/presenters/LeagueOwnerSummaryPresenter.test.ts @@ -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(); + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/league/presenters/LeagueOwnerSummaryPresenter.ts b/apps/api/src/domain/league/presenters/LeagueOwnerSummaryPresenter.ts new file mode 100644 index 000000000..2ae2e2450 --- /dev/null +++ b/apps/api/src/domain/league/presenters/LeagueOwnerSummaryPresenter.ts @@ -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; + } +} \ No newline at end of file diff --git a/apps/api/src/domain/league/presenters/LeagueSchedulePresenter.ts b/apps/api/src/domain/league/presenters/LeagueSchedulePresenter.ts index 346d6ee11..c5d601d3d 100644 --- a/apps/api/src/domain/league/presenters/LeagueSchedulePresenter.ts +++ b/apps/api/src/domain/league/presenters/LeagueSchedulePresenter.ts @@ -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(race => ({ +export class LeagueSchedulePresenter { + private result: LeagueScheduleDTO | null = null; + + reset() { + this.result = null; + } + + present(output: GetLeagueScheduleOutputPort, leagueName?: string) { + this.result = { + races: output.races.map(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(race => ({ id: race.id, name: race.name, date: race.scheduledAt.toISOString(), leagueName, - })), - }; -} + })); + } -export function mapGetLeagueScheduleOutputPortToRaceDTOs(output: GetLeagueScheduleOutputPort, leagueName?: string): RaceDTO[] { - return output.races.map(race => ({ - id: race.id, - name: race.name, - date: race.scheduledAt.toISOString(), - leagueName, - })); + getViewModel(): RaceDTO[] | null { + return this.result; + } } \ No newline at end of file diff --git a/apps/api/src/domain/league/presenters/RejectLeagueJoinRequestPresenter.ts b/apps/api/src/domain/league/presenters/RejectLeagueJoinRequestPresenter.ts index c65eeef8e..df8229b61 100644 --- a/apps/api/src/domain/league/presenters/RejectLeagueJoinRequestPresenter.ts +++ b/apps/api/src/domain/league/presenters/RejectLeagueJoinRequestPresenter.ts @@ -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; + } } \ No newline at end of file diff --git a/apps/api/src/domain/league/presenters/RemoveLeagueMemberPresenter.ts b/apps/api/src/domain/league/presenters/RemoveLeagueMemberPresenter.ts index 242f09fe5..bbcde12f3 100644 --- a/apps/api/src/domain/league/presenters/RemoveLeagueMemberPresenter.ts +++ b/apps/api/src/domain/league/presenters/RemoveLeagueMemberPresenter.ts @@ -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; + } } \ No newline at end of file diff --git a/apps/api/src/domain/league/presenters/TransferLeagueOwnershipPresenter.ts b/apps/api/src/domain/league/presenters/TransferLeagueOwnershipPresenter.ts index ec3b7d1d1..81c9a0dd4 100644 --- a/apps/api/src/domain/league/presenters/TransferLeagueOwnershipPresenter.ts +++ b/apps/api/src/domain/league/presenters/TransferLeagueOwnershipPresenter.ts @@ -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; + } } \ No newline at end of file diff --git a/apps/api/src/domain/league/presenters/UpdateLeagueMemberRolePresenter.ts b/apps/api/src/domain/league/presenters/UpdateLeagueMemberRolePresenter.ts index 5b7065b1a..0376d4c6b 100644 --- a/apps/api/src/domain/league/presenters/UpdateLeagueMemberRolePresenter.ts +++ b/apps/api/src/domain/league/presenters/UpdateLeagueMemberRolePresenter.ts @@ -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; + } } \ No newline at end of file diff --git a/apps/api/src/domain/media/MediaController.test.ts b/apps/api/src/domain/media/MediaController.test.ts index cdb1d2a9f..1cecf4e33 100644 --- a/apps/api/src/domain/media/MediaController.test.ts +++ b/apps/api/src/domain/media/MediaController.test.ts @@ -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> = { 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> = { 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> = { 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> = { 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> = { 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> = { 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); }); }); diff --git a/apps/api/src/domain/media/MediaController.ts b/apps/api/src/domain/media/MediaController.ts index 1bda48b8a..0597f78ed 100644 --- a/apps/api/src/domain/media/MediaController.ts +++ b/apps/api/src/domain/media/MediaController.ts @@ -29,11 +29,13 @@ export class MediaController { @Body() input: RequestAvatarGenerationInput, @Res() res: Response, ): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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); } } diff --git a/apps/api/src/domain/media/MediaService.ts b/apps/api/src/domain/media/MediaService.ts index 3eb10870d..ea7474986 100644 --- a/apps/api/src/domain/media/MediaService.ts +++ b/apps/api/src/domain/media/MediaService.ts @@ -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 { + async requestAvatarGeneration(input: RequestAvatarGenerationInput): Promise { 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 { + async uploadMedia(input: UploadMediaInput & { file: Express.Multer.File }): Promise { 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 { + async getMedia(mediaId: string): Promise { 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 { + async deleteMedia(mediaId: string): Promise { 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 { + async getAvatar(driverId: string): Promise { 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 { + async updateAvatar(driverId: string, input: UpdateAvatarInput): Promise { 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; } } diff --git a/apps/api/src/domain/media/presenters/DeleteMediaPresenter.ts b/apps/api/src/domain/media/presenters/DeleteMediaPresenter.ts index 934463817..d57a1da68 100644 --- a/apps/api/src/domain/media/presenters/DeleteMediaPresenter.ts +++ b/apps/api/src/domain/media/presenters/DeleteMediaPresenter.ts @@ -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, + }; } } \ No newline at end of file diff --git a/apps/api/src/domain/media/presenters/GetAvatarPresenter.ts b/apps/api/src/domain/media/presenters/GetAvatarPresenter.ts index 298c35608..b08c867fb 100644 --- a/apps/api/src/domain/media/presenters/GetAvatarPresenter.ts +++ b/apps/api/src/domain/media/presenters/GetAvatarPresenter.ts @@ -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, + }; } } \ No newline at end of file diff --git a/apps/api/src/domain/media/presenters/GetMediaPresenter.ts b/apps/api/src/domain/media/presenters/GetMediaPresenter.ts index 07ccf8e9d..c21f70af4 100644 --- a/apps/api/src/domain/media/presenters/GetMediaPresenter.ts +++ b/apps/api/src/domain/media/presenters/GetMediaPresenter.ts @@ -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, + }; } } \ No newline at end of file diff --git a/apps/api/src/domain/media/presenters/UpdateAvatarPresenter.ts b/apps/api/src/domain/media/presenters/UpdateAvatarPresenter.ts index 6c3fb7a59..f98af42f7 100644 --- a/apps/api/src/domain/media/presenters/UpdateAvatarPresenter.ts +++ b/apps/api/src/domain/media/presenters/UpdateAvatarPresenter.ts @@ -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, + }; } } \ No newline at end of file diff --git a/apps/api/src/domain/media/presenters/UploadMediaPresenter.ts b/apps/api/src/domain/media/presenters/UploadMediaPresenter.ts index 6c0afe0cc..27487c836 100644 --- a/apps/api/src/domain/media/presenters/UploadMediaPresenter.ts +++ b/apps/api/src/domain/media/presenters/UploadMediaPresenter.ts @@ -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', + }; } } \ No newline at end of file diff --git a/apps/api/src/domain/payments/PaymentsController.test.ts b/apps/api/src/domain/payments/PaymentsController.test.ts index 228e134c6..463b3d2b5 100644 --- a/apps/api/src/domain/payments/PaymentsController.test.ts +++ b/apps/api/src/domain/payments/PaymentsController.test.ts @@ -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); }); }); -}); \ No newline at end of file +}); diff --git a/apps/api/src/domain/payments/PaymentsController.ts b/apps/api/src/domain/payments/PaymentsController.ts index f9b56ced3..80846396f 100644 --- a/apps/api/src/domain/payments/PaymentsController.ts +++ b/apps/api/src/domain/payments/PaymentsController.ts @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - return this.paymentsService.processWalletTransaction(input); + const presenter = await this.paymentsService.processWalletTransaction(input); + return presenter.viewModel; } } diff --git a/apps/api/src/domain/payments/PaymentsService.ts b/apps/api/src/domain/payments/PaymentsService.ts index a4ce02ee6..9ed48ca08 100644 --- a/apps/api/src/domain/payments/PaymentsService.ts +++ b/apps/api/src/domain/payments/PaymentsService.ts @@ -92,99 +92,99 @@ export class PaymentsService { @Inject(LOGGER_TOKEN) private readonly logger: Logger, ) {} - async getPayments(query: GetPaymentsQuery): Promise { + async getPayments(query: GetPaymentsQuery): Promise { 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 { + async createPayment(input: CreatePaymentInput): Promise { 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 { + async updatePaymentStatus(input: UpdatePaymentStatusInput): Promise { 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 { + async getMembershipFees(query: GetMembershipFeesQuery): Promise { 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 { + async upsertMembershipFee(input: UpsertMembershipFeeInput): Promise { 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 { + async updateMemberPayment(input: UpdateMemberPaymentInput): Promise { 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 { + async getPrizes(query: GetPrizesQuery): Promise { 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 { + async createPrize(input: CreatePrizeInput): Promise { 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 { + async awardPrize(input: AwardPrizeInput): Promise { 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 { + async deletePrize(input: DeletePrizeInput): Promise { 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 { + async getWallet(query: GetWalletQuery): Promise { 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 { + async processWalletTransaction(input: ProcessWalletTransactionInput): Promise { this.logger.debug('[PaymentsService] Processing wallet transaction', { input }); const presenter = new ProcessWalletTransactionPresenter(); await this.processWalletTransactionUseCase.execute(input, presenter); - return presenter.viewModel; + return presenter; } } diff --git a/apps/api/src/domain/protests/ProtestsController.test.ts b/apps/api/src/domain/protests/ProtestsController.test.ts index dc6fd01d3..adb26b7c9 100644 --- a/apps/api/src/domain/protests/ProtestsController.test.ts +++ b/apps/api/src/domain/protests/ProtestsController.test.ts @@ -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>; + let reviewProtestMock: MockedFunction; 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); - 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 = { decision: 'upheld', reason: 'Reason' }; - raceService.reviewProtest.mockResolvedValue(undefined); + const body: Omit = { + 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 = { + 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 = { + 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 = { + 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); }); }); -}); \ No newline at end of file +}); diff --git a/apps/api/src/domain/protests/ProtestsController.ts b/apps/api/src/domain/protests/ProtestsController.ts index 861b6b1c8..3113af84d 100644 --- a/apps/api/src/domain/protests/ProtestsController.ts +++ b/apps/api/src/domain/protests/ProtestsController.ts @@ -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, ): Promise { - 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'); + } + } } -} \ No newline at end of file +} diff --git a/apps/api/src/domain/protests/ProtestsService.test.ts b/apps/api/src/domain/protests/ProtestsService.test.ts new file mode 100644 index 000000000..b0a6c99a3 --- /dev/null +++ b/apps/api/src/domain/protests/ProtestsService.test.ts @@ -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; + 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(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', + }); + }); +}); diff --git a/apps/api/src/domain/protests/ProtestsService.ts b/apps/api/src/domain/protests/ProtestsService.ts index 72b2393d8..d231b5f39 100644 --- a/apps/api/src/domain/protests/ProtestsService.ts +++ b/apps/api/src/domain/protests/ProtestsService.ts @@ -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 { + }): Promise { 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; } -} \ No newline at end of file +} diff --git a/apps/api/src/domain/protests/presenters/ReviewProtestPresenter.ts b/apps/api/src/domain/protests/presenters/ReviewProtestPresenter.ts new file mode 100644 index 000000000..d11ca6fdb --- /dev/null +++ b/apps/api/src/domain/protests/presenters/ReviewProtestPresenter.ts @@ -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; + } +} diff --git a/apps/api/src/domain/race/RaceController.test.ts b/apps/api/src/domain/race/RaceController.test.ts index 9a08411e7..b0b23de0e 100644 --- a/apps/api/src/domain/race/RaceController.test.ts +++ b/apps/api/src/domain/race/RaceController.test.ts @@ -26,7 +26,7 @@ describe('RaceController', () => { applyQuickPenalty: jest.fn(), applyPenalty: jest.fn(), requestProtestDefense: jest.fn(), - }; + } as unknown as jest.Mocked; const module: TestingModule = await Test.createTestingModule({ controllers: [RaceController], @@ -39,7 +39,7 @@ describe('RaceController', () => { }).compile(); controller = module.get(RaceController); - service = module.get(RaceService); + service = module.get(RaceService) as jest.Mocked; }); 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); 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); const result = await controller.getTotalRaces(); expect(service.getTotalRaces).toHaveBeenCalled(); - expect(result).toEqual(mockResult); + expect(result).toEqual(mockViewModel); }); }); - - // Add more tests as needed }); \ No newline at end of file diff --git a/apps/api/src/domain/race/RaceController.ts b/apps/api/src/domain/race/RaceController.ts index dc00092b1..cc92c5886 100644 --- a/apps/api/src/domain/race/RaceController.ts +++ b/apps/api/src/domain/race/RaceController.ts @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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, ): Promise { - 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, ): Promise { - 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 { - 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 { - 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 { - 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, ): Promise { - 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 { - 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 { - 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 { - 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 { - 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'); + } } } diff --git a/apps/api/src/domain/race/RaceProviders.ts b/apps/api/src/domain/race/RaceProviders.ts index 163a00afb..32f271b31 100644 --- a/apps/api/src/domain/race/RaceProviders.ts +++ b/apps/api/src/domain/race/RaceProviders.ts @@ -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'; diff --git a/apps/api/src/domain/race/RaceService.test.ts b/apps/api/src/domain/race/RaceService.test.ts new file mode 100644 index 000000000..937adc967 --- /dev/null +++ b/apps/api/src/domain/race/RaceService.test.ts @@ -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; + let getTotalRacesUseCase: jest.Mocked; + let importRaceResultsApiUseCase: jest.Mocked; + let getRaceDetailUseCase: jest.Mocked; + let getRacesPageDataUseCase: jest.Mocked; + let getAllRacesPageDataUseCase: jest.Mocked; + let getRaceResultsDetailUseCase: jest.Mocked; + let getRaceWithSOFUseCase: jest.Mocked; + let getRaceProtestsUseCase: jest.Mocked; + let getRacePenaltiesUseCase: jest.Mocked; + let registerForRaceUseCase: jest.Mocked; + let withdrawFromRaceUseCase: jest.Mocked; + let cancelRaceUseCase: jest.Mocked; + let completeRaceUseCase: jest.Mocked; + let fileProtestUseCase: jest.Mocked; + let quickPenaltyUseCase: jest.Mocked; + let applyPenaltyUseCase: jest.Mocked; + let requestProtestDefenseUseCase: jest.Mocked; + let reviewProtestUseCase: jest.Mocked; + let reopenRaceUseCase: jest.Mocked; + let leagueRepository: jest.Mocked; + let logger: jest.Mocked; + let driverRatingProvider: jest.Mocked; + let imageService: jest.Mocked; + + beforeEach(() => { + getAllRacesUseCase = { execute: jest.fn() } as jest.Mocked; + getTotalRacesUseCase = { execute: jest.fn() } as jest.Mocked; + importRaceResultsApiUseCase = { execute: jest.fn() } as jest.Mocked; + getRaceDetailUseCase = { execute: jest.fn() } as jest.Mocked; + getRacesPageDataUseCase = { execute: jest.fn() } as jest.Mocked; + getAllRacesPageDataUseCase = { execute: jest.fn() } as jest.Mocked; + getRaceResultsDetailUseCase = { execute: jest.fn() } as jest.Mocked; + getRaceWithSOFUseCase = { execute: jest.fn() } as jest.Mocked; + getRaceProtestsUseCase = { execute: jest.fn() } as jest.Mocked; + getRacePenaltiesUseCase = { execute: jest.fn() } as jest.Mocked; + registerForRaceUseCase = { execute: jest.fn() } as jest.Mocked; + withdrawFromRaceUseCase = { execute: jest.fn() } as jest.Mocked; + cancelRaceUseCase = { execute: jest.fn() } as jest.Mocked; + completeRaceUseCase = { execute: jest.fn() } as jest.Mocked; + fileProtestUseCase = { execute: jest.fn() } as jest.Mocked; + quickPenaltyUseCase = { execute: jest.fn() } as jest.Mocked; + applyPenaltyUseCase = { execute: jest.fn() } as jest.Mocked; + requestProtestDefenseUseCase = { execute: jest.fn() } as jest.Mocked; + reviewProtestUseCase = { execute: jest.fn() } as jest.Mocked; + reopenRaceUseCase = { execute: jest.fn() } as jest.Mocked; + + leagueRepository = { + findAll: jest.fn(), + } as jest.Mocked; + + logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } as jest.Mocked; + + driverRatingProvider = { + getDriverRating: jest.fn(), + } as jest.Mocked; + + imageService = { + getDriverAvatar: jest.fn(), + getTeamLogo: jest.fn(), + getLeagueCover: jest.fn(), + getLeagueLogo: jest.fn(), + } as jest.Mocked; + + 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'); + }); +}); diff --git a/apps/api/src/domain/race/RaceService.ts b/apps/api/src/domain/race/RaceService.ts index 203d54f45..dea5a122f 100644 --- a/apps/api/src/domain/race/RaceService.ts +++ b/apps/api/src/domain/race/RaceService.ts @@ -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 { + async getAllRaces(): Promise { 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 { + async getTotalRaces(): Promise { 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 { + async importRaceResults(input: ImportRaceResultsDTO): Promise { 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 { + async getRaceDetail(params: GetRaceDetailParamsDTO): Promise { 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 { + async getRacesPageData(): Promise { 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 { + async getAllRacesPageData(): Promise { 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 { + async getRaceResultsDetail(raceId: string): Promise { 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 { + async getRaceWithSOF(raceId: string): Promise { 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 { + async getRaceProtests(raceId: string): Promise { 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 = {}; - 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 { + async getRacePenalties(raceId: string): Promise { 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 = {}; - 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 { + async registerForRace(params: RegisterForRaceParamsDTO): Promise { 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 { + async withdrawFromRace(params: WithdrawFromRaceParamsDTO): Promise { 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 { + async cancelRace(params: RaceActionParamsDTO): Promise { 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 { + async completeRace(params: RaceActionParamsDTO): Promise { 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 { + async reopenRace(params: RaceActionParamsDTO): Promise { 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 { + async fileProtest(command: FileProtestCommandDTO): Promise { 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 { + async applyQuickPenalty(command: QuickPenaltyCommandDTO): Promise { 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 { + async applyPenalty(command: ApplyPenaltyCommandDTO): Promise { 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 { + async requestProtestDefense(command: RequestProtestDefenseCommandDTO): Promise { 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 { + async reviewProtest(command: ReviewProtestCommandDTO): Promise { 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; } } diff --git a/apps/api/src/domain/race/dtos/DashboardOverviewDTO.ts b/apps/api/src/domain/race/dtos/DashboardOverviewDTO.ts index 9ad92bf97..162d8ccb6 100644 --- a/apps/api/src/domain/race/dtos/DashboardOverviewDTO.ts +++ b/apps/api/src/domain/race/dtos/DashboardOverviewDTO.ts @@ -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'; diff --git a/apps/api/src/domain/race/dtos/FileProtestCommandDTO.ts b/apps/api/src/domain/race/dtos/FileProtestCommandDTO.ts index 5d92e0a5e..24bbb3b53 100644 --- a/apps/api/src/domain/race/dtos/FileProtestCommandDTO.ts +++ b/apps/api/src/domain/race/dtos/FileProtestCommandDTO.ts @@ -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() diff --git a/apps/api/src/domain/race/dtos/RaceDetailEntryDTO.ts b/apps/api/src/domain/race/dtos/RaceDetailEntryDTO.ts index 64a809aa6..8e335d41c 100644 --- a/apps/api/src/domain/race/dtos/RaceDetailEntryDTO.ts +++ b/apps/api/src/domain/race/dtos/RaceDetailEntryDTO.ts @@ -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() diff --git a/apps/api/src/domain/race/presenters/AllRacesPageDataPresenter.ts b/apps/api/src/domain/race/presenters/AllRacesPageDataPresenter.ts new file mode 100644 index 000000000..32fa7896a --- /dev/null +++ b/apps/api/src/domain/race/presenters/AllRacesPageDataPresenter.ts @@ -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; + } +} diff --git a/apps/api/src/domain/race/presenters/CommandResultPresenter.ts b/apps/api/src/domain/race/presenters/CommandResultPresenter.ts new file mode 100644 index 000000000..0e6d5848c --- /dev/null +++ b/apps/api/src/domain/race/presenters/CommandResultPresenter.ts @@ -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; + } +} diff --git a/apps/api/src/domain/race/presenters/RaceDetailPresenter.ts b/apps/api/src/domain/race/presenters/RaceDetailPresenter.ts new file mode 100644 index 000000000..302405228 --- /dev/null +++ b/apps/api/src/domain/race/presenters/RaceDetailPresenter.ts @@ -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 { + 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; + } +} diff --git a/apps/api/src/domain/race/presenters/RacePenaltiesPresenter.ts b/apps/api/src/domain/race/presenters/RacePenaltiesPresenter.ts new file mode 100644 index 000000000..3cdac78c6 --- /dev/null +++ b/apps/api/src/domain/race/presenters/RacePenaltiesPresenter.ts @@ -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 = {}; + 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; + } +} diff --git a/apps/api/src/domain/race/presenters/RaceProtestsPresenter.ts b/apps/api/src/domain/race/presenters/RaceProtestsPresenter.ts new file mode 100644 index 000000000..abdc1d509 --- /dev/null +++ b/apps/api/src/domain/race/presenters/RaceProtestsPresenter.ts @@ -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 = {}; + 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; + } +} diff --git a/apps/api/src/domain/race/presenters/RaceResultsDetailPresenter.ts b/apps/api/src/domain/race/presenters/RaceResultsDetailPresenter.ts new file mode 100644 index 000000000..02d2ebfae --- /dev/null +++ b/apps/api/src/domain/race/presenters/RaceResultsDetailPresenter.ts @@ -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 { + 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; + } +} diff --git a/apps/api/src/domain/race/presenters/RaceWithSOFPresenter.ts b/apps/api/src/domain/race/presenters/RaceWithSOFPresenter.ts new file mode 100644 index 000000000..010e56e6f --- /dev/null +++ b/apps/api/src/domain/race/presenters/RaceWithSOFPresenter.ts @@ -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; + } +} diff --git a/apps/api/src/domain/race/presenters/RacesPageDataPresenter.ts b/apps/api/src/domain/race/presenters/RacesPageDataPresenter.ts new file mode 100644 index 000000000..b918f0a8b --- /dev/null +++ b/apps/api/src/domain/race/presenters/RacesPageDataPresenter.ts @@ -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 { + 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; + } +} diff --git a/apps/api/src/domain/sponsor/SponsorController.test.ts b/apps/api/src/domain/sponsor/SponsorController.test.ts index b6be2d514..bd5d28001 100644 --- a/apps/api/src/domain/sponsor/SponsorController.test.ts +++ b/apps/api/src/domain/sponsor/SponsorController.test.ts @@ -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(); }); }); -}); \ No newline at end of file + + 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); + }); + }); +}); diff --git a/apps/api/src/domain/sponsor/SponsorController.ts b/apps/api/src/domain/sponsor/SponsorController.ts index 44b92ab3f..5cc97da39 100644 --- a/apps/api/src/domain/sponsor/SponsorController.ts +++ b/apps/api/src/domain/sponsor/SponsorController.ts @@ -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 { - 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 { - 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 { - 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 { - return this.sponsorService.getSponsorDashboard({ sponsorId } as GetSponsorDashboardQueryParamsDTO); + async getSponsorDashboard( + @Param('sponsorId') sponsorId: string, + ): Promise { + 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 { - return this.sponsorService.getSponsorSponsorships({ sponsorId } as GetSponsorSponsorshipsQueryParamsDTO); + async getSponsorSponsorships( + @Param('sponsorId') sponsorId: string, + ): Promise { + 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 { - 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 { - 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 { + 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 { - 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 { + 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 { - 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 { + 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 { - 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 { + 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; - notifications?: Partial; - privacy?: Partial; - } - ): Promise { - 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; + notifications?: Partial; + privacy?: Partial; + }, + ): Promise<{ success: boolean; errorCode?: string; message?: string } | null> { + const presenter = await this.sponsorService.updateSponsorSettings(sponsorId, input); + return presenter.viewModel; + } } diff --git a/apps/api/src/domain/sponsor/SponsorService.test.ts b/apps/api/src/domain/sponsor/SponsorService.test.ts index 95ad78186..e7676e55a 100644 --- a/apps/api/src/domain/sponsor/SponsorService.test.ts +++ b/apps/api/src/domain/sponsor/SponsorService.test.ts @@ -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(); }); }); -}); \ No newline at end of file + + 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 }); + }); + }); +}); diff --git a/apps/api/src/domain/sponsor/SponsorService.ts b/apps/api/src/domain/sponsor/SponsorService.ts index 61dd5780e..d59273fbe 100644 --- a/apps/api/src/domain/sponsor/SponsorService.ts +++ b/apps/api/src/domain/sponsor/SponsorService.ts @@ -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 { + async getEntitySponsorshipPricing(): Promise { 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 { + async getSponsors(): Promise { 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 { + async createSponsor(input: CreateSponsorInputDTO): Promise { 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 { + async getSponsorDashboard( + params: GetSponsorDashboardQueryParamsDTO, + ): Promise { 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 { + async getSponsorSponsorships( + params: GetSponsorSponsorshipsQueryParamsDTO, + ): Promise { 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 { + async getSponsor(sponsorId: string): Promise { 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 { + async getPendingSponsorshipRequests(params: { + entityType: SponsorableEntityType; + entityId: string; + }): Promise { 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 { - this.logger.debug('[SponsorService] Accepting sponsorship request.', { requestId, respondedBy }); + async acceptSponsorshipRequest( + requestId: string, + respondedBy: string, + ): Promise { + 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 { - this.logger.debug('[SponsorService] Rejecting sponsorship request.', { requestId, respondedBy, reason }); + async rejectSponsorshipRequest( + requestId: string, + respondedBy: string, + reason?: string, + ): Promise { + 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 { 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 { + async getAvailableLeagues(): Promise { 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 { 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 { 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; notifications?: Partial; privacy?: Partial; - } - ): Promise { + }, + ): Promise { 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; } } diff --git a/apps/api/src/domain/sponsor/presenters/AcceptSponsorshipRequestPresenter.ts b/apps/api/src/domain/sponsor/presenters/AcceptSponsorshipRequestPresenter.ts new file mode 100644 index 000000000..b47cb06bd --- /dev/null +++ b/apps/api/src/domain/sponsor/presenters/AcceptSponsorshipRequestPresenter.ts @@ -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; + } +} diff --git a/apps/api/src/domain/sponsor/presenters/AvailableLeaguesPresenter.ts b/apps/api/src/domain/sponsor/presenters/AvailableLeaguesPresenter.ts new file mode 100644 index 000000000..0766a75c0 --- /dev/null +++ b/apps/api/src/domain/sponsor/presenters/AvailableLeaguesPresenter.ts @@ -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; + } +} diff --git a/apps/api/src/domain/sponsor/presenters/GetEntitySponsorshipPricingPresenter.ts b/apps/api/src/domain/sponsor/presenters/GetEntitySponsorshipPricingPresenter.ts index b8b28e434..6d44d8ad0 100644 --- a/apps/api/src/domain/sponsor/presenters/GetEntitySponsorshipPricingPresenter.ts +++ b/apps/api/src/domain/sponsor/presenters/GetEntitySponsorshipPricingPresenter.ts @@ -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; } -} \ No newline at end of file + + get viewModel(): GetEntitySponsorshipPricingResultDTO { + if (!this.result) throw new Error('Presenter not presented'); + return this.result; + } +} diff --git a/apps/api/src/domain/sponsor/presenters/GetPendingSponsorshipRequestsPresenter.ts b/apps/api/src/domain/sponsor/presenters/GetPendingSponsorshipRequestsPresenter.ts index 169a2b774..c6c90ebd8 100644 --- a/apps/api/src/domain/sponsor/presenters/GetPendingSponsorshipRequestsPresenter.ts +++ b/apps/api/src/domain/sponsor/presenters/GetPendingSponsorshipRequestsPresenter.ts @@ -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, }; } -} \ No newline at end of file + + getViewModel(): GetPendingSponsorshipRequestsOutputDTO | null { + return this.result; + } + + get viewModel(): GetPendingSponsorshipRequestsOutputDTO | null { + return this.result; + } +} diff --git a/apps/api/src/domain/sponsor/presenters/GetSponsorDashboardPresenter.ts b/apps/api/src/domain/sponsor/presenters/GetSponsorDashboardPresenter.ts index 6619fcf7f..86154b71a 100644 --- a/apps/api/src/domain/sponsor/presenters/GetSponsorDashboardPresenter.ts +++ b/apps/api/src/domain/sponsor/presenters/GetSponsorDashboardPresenter.ts @@ -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; } -} \ No newline at end of file + + present(outputPort: SponsorDashboardOutputPort | null) { + this.result = outputPort ?? null; + } + + getViewModel(): SponsorDashboardDTO | null { + return this.result; + } + + get viewModel(): SponsorDashboardDTO | null { + return this.result; + } +} diff --git a/apps/api/src/domain/sponsor/presenters/GetSponsorPresenter.ts b/apps/api/src/domain/sponsor/presenters/GetSponsorPresenter.ts new file mode 100644 index 000000000..52c1f6ff4 --- /dev/null +++ b/apps/api/src/domain/sponsor/presenters/GetSponsorPresenter.ts @@ -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; + } +} diff --git a/apps/api/src/domain/sponsor/presenters/GetSponsorSponsorshipsPresenter.ts b/apps/api/src/domain/sponsor/presenters/GetSponsorSponsorshipsPresenter.ts index 84b7a0dc6..3bfec6139 100644 --- a/apps/api/src/domain/sponsor/presenters/GetSponsorSponsorshipsPresenter.ts +++ b/apps/api/src/domain/sponsor/presenters/GetSponsorSponsorshipsPresenter.ts @@ -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; } -} \ No newline at end of file + + present(outputPort: SponsorSponsorshipsOutputPort | null) { + this.result = outputPort ?? null; + } + + getViewModel(): SponsorSponsorshipsDTO | null { + return this.result; + } + + get viewModel(): SponsorSponsorshipsDTO | null { + return this.result; + } +} diff --git a/apps/api/src/domain/sponsor/presenters/GetSponsorsPresenter.ts b/apps/api/src/domain/sponsor/presenters/GetSponsorsPresenter.ts index cb2d98098..40ea18418 100644 --- a/apps/api/src/domain/sponsor/presenters/GetSponsorsPresenter.ts +++ b/apps/api/src/domain/sponsor/presenters/GetSponsorsPresenter.ts @@ -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, }; } -} \ No newline at end of file + + getViewModel(): GetSponsorsOutputDTO | null { + return this.result; + } + + get viewModel(): GetSponsorsOutputDTO { + if (!this.result) throw new Error('Presenter not presented'); + return this.result; + } +} diff --git a/apps/api/src/domain/sponsor/presenters/LeagueDetailPresenter.ts b/apps/api/src/domain/sponsor/presenters/LeagueDetailPresenter.ts new file mode 100644 index 000000000..996c55e9a --- /dev/null +++ b/apps/api/src/domain/sponsor/presenters/LeagueDetailPresenter.ts @@ -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; + } +} diff --git a/apps/api/src/domain/sponsor/presenters/RejectSponsorshipRequestPresenter.ts b/apps/api/src/domain/sponsor/presenters/RejectSponsorshipRequestPresenter.ts new file mode 100644 index 000000000..85fee1b19 --- /dev/null +++ b/apps/api/src/domain/sponsor/presenters/RejectSponsorshipRequestPresenter.ts @@ -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; + } +} diff --git a/apps/api/src/domain/sponsor/presenters/SponsorBillingPresenter.ts b/apps/api/src/domain/sponsor/presenters/SponsorBillingPresenter.ts new file mode 100644 index 000000000..a98e35998 --- /dev/null +++ b/apps/api/src/domain/sponsor/presenters/SponsorBillingPresenter.ts @@ -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; + } +} diff --git a/apps/api/src/domain/sponsor/presenters/SponsorSettingsPresenter.ts b/apps/api/src/domain/sponsor/presenters/SponsorSettingsPresenter.ts new file mode 100644 index 000000000..0941d5f2f --- /dev/null +++ b/apps/api/src/domain/sponsor/presenters/SponsorSettingsPresenter.ts @@ -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; + } +} diff --git a/apps/api/src/domain/sponsor/presenters/SponsorSettingsUpdatePresenter.ts b/apps/api/src/domain/sponsor/presenters/SponsorSettingsUpdatePresenter.ts new file mode 100644 index 000000000..ddb8107cc --- /dev/null +++ b/apps/api/src/domain/sponsor/presenters/SponsorSettingsUpdatePresenter.ts @@ -0,0 +1,25 @@ +export interface SponsorSettingsUpdateViewModel { + success: boolean; + errorCode?: string; + message?: string; +} + +export class SponsorSettingsUpdatePresenter { + private result: SponsorSettingsUpdateViewModel | null = null; + + reset() { + this.result = null; + } + + present(viewModel: SponsorSettingsUpdateViewModel) { + this.result = viewModel; + } + + getViewModel(): SponsorSettingsUpdateViewModel | null { + return this.result; + } + + get viewModel(): SponsorSettingsUpdateViewModel | null { + return this.result; + } +} diff --git a/apps/api/src/domain/team/TeamController.ts b/apps/api/src/domain/team/TeamController.ts index db0502915..fdf68e8f6 100644 --- a/apps/api/src/domain/team/TeamController.ts +++ b/apps/api/src/domain/team/TeamController.ts @@ -22,7 +22,8 @@ export class TeamController { @ApiOperation({ summary: 'Get all teams' }) @ApiResponse({ status: 200, description: 'List of all teams', type: GetAllTeamsOutputDTO }) async getAll(): Promise { - return this.teamService.getAll(); + const presenter = await this.teamService.getAll(); + return presenter.viewModel; } @Get(':teamId') @@ -31,21 +32,24 @@ export class TeamController { @ApiResponse({ status: 404, description: 'Team not found' }) async getDetails(@Param('teamId') teamId: string, @Req() req: Request): Promise { const userId = req['user']?.userId; - return this.teamService.getDetails(teamId, userId); + const presenter = await this.teamService.getDetails(teamId, userId); + return presenter.getViewModel(); } @Get(':teamId/members') @ApiOperation({ summary: 'Get team members' }) @ApiResponse({ status: 200, description: 'Team members', type: GetTeamMembersOutputDTO }) async getMembers(@Param('teamId') teamId: string): Promise { - return this.teamService.getMembers(teamId); + const presenter = await this.teamService.getMembers(teamId); + return presenter.getViewModel()!; } @Get(':teamId/join-requests') @ApiOperation({ summary: 'Get team join requests' }) @ApiResponse({ status: 200, description: 'Team join requests', type: GetTeamJoinRequestsOutputDTO }) async getJoinRequests(@Param('teamId') teamId: string): Promise { - return this.teamService.getJoinRequests(teamId); + const presenter = await this.teamService.getJoinRequests(teamId); + return presenter.getViewModel()!; } @Post() @@ -53,7 +57,8 @@ export class TeamController { @ApiResponse({ status: 201, description: 'Team created', type: CreateTeamOutputDTO }) async create(@Body() input: CreateTeamInputDTO, @Req() req: Request): Promise { const userId = req['user']?.userId; - return this.teamService.create(input, userId); + const presenter = await this.teamService.create(input, userId); + return presenter.viewModel; } @Patch(':teamId') @@ -61,7 +66,8 @@ export class TeamController { @ApiResponse({ status: 200, description: 'Team updated', type: UpdateTeamOutputDTO }) async update(@Param('teamId') teamId: string, @Body() input: UpdateTeamInputDTO, @Req() req: Request): Promise { const userId = req['user']?.userId; - return this.teamService.update(teamId, input, userId); + const presenter = await this.teamService.update(teamId, input, userId); + return presenter.viewModel; } @Get('driver/:driverId') @@ -69,14 +75,16 @@ export class TeamController { @ApiResponse({ status: 200, description: 'Driver\'s team', type: GetDriverTeamOutputDTO }) @ApiResponse({ status: 404, description: 'Team not found' }) async getDriverTeam(@Param('driverId') driverId: string): Promise { - return this.teamService.getDriverTeam(driverId); + const presenter = await this.teamService.getDriverTeam(driverId); + return presenter.getViewModel(); } - + @Get(':teamId/members/:driverId') @ApiOperation({ summary: 'Get team membership for a driver' }) @ApiResponse({ status: 200, description: 'Team membership', type: GetTeamMembershipOutputDTO }) @ApiResponse({ status: 404, description: 'Membership not found' }) async getMembership(@Param('teamId') teamId: string, @Param('driverId') driverId: string): Promise { - return this.teamService.getMembership(teamId, driverId); + const presenter = await this.teamService.getMembership(teamId, driverId); + return presenter.viewModel; } } \ No newline at end of file diff --git a/apps/api/src/domain/team/TeamService.test.ts b/apps/api/src/domain/team/TeamService.test.ts index 007d23bed..b3137b8dd 100644 --- a/apps/api/src/domain/team/TeamService.test.ts +++ b/apps/api/src/domain/team/TeamService.test.ts @@ -5,7 +5,6 @@ import { GetDriverTeamUseCase } from '@core/racing/application/use-cases/GetDriv import type { Logger } from '@core/shared/application/Logger'; import { AllTeamsPresenter } from './presenters/AllTeamsPresenter'; import { DriverTeamPresenter } from './presenters/DriverTeamPresenter'; -import { AllTeamsViewModel, DriverTeamViewModel } from './dtos/TeamDto'; describe('TeamService', () => { let service: TeamService; diff --git a/apps/api/src/domain/team/TeamService.ts b/apps/api/src/domain/team/TeamService.ts index 2790f3ae0..4bc67dae9 100644 --- a/apps/api/src/domain/team/TeamService.ts +++ b/apps/api/src/domain/team/TeamService.ts @@ -1,14 +1,6 @@ import { Injectable, Inject } from '@nestjs/common'; -import { GetAllTeamsOutputDTO } from './dtos/GetAllTeamsOutputDTO'; -import { GetTeamDetailsOutputDTO } from './dtos/GetTeamDetailsOutputDTO'; -import { GetTeamMembersOutputDTO } from './dtos/GetTeamMembersOutputDTO'; -import { GetTeamJoinRequestsOutputDTO } from './dtos/GetTeamJoinRequestsOutputDTO'; import { CreateTeamInputDTO } from './dtos/CreateTeamInputDTO'; -import { CreateTeamOutputDTO } from './dtos/CreateTeamOutputDTO'; import { UpdateTeamInputDTO } from './dtos/UpdateTeamInputDTO'; -import { UpdateTeamOutputDTO } from './dtos/UpdateTeamOutputDTO'; -import { GetDriverTeamOutputDTO } from './dtos/GetDriverTeamOutputDTO'; -import { GetTeamMembershipOutputDTO } from './dtos/GetTeamMembershipOutputDTO'; // Core imports import type { Logger } from '@core/shared/application/Logger'; @@ -29,6 +21,9 @@ import { TeamDetailsPresenter } from './presenters/TeamDetailsPresenter'; import { TeamMembersPresenter } from './presenters/TeamMembersPresenter'; import { TeamJoinRequestsPresenter } from './presenters/TeamJoinRequestsPresenter'; import { DriverTeamPresenter } from './presenters/DriverTeamPresenter'; +import { TeamMembershipPresenter } from './presenters/TeamMembershipPresenter'; +import { CreateTeamPresenter } from './presenters/CreateTeamPresenter'; +import { UpdateTeamPresenter } from './presenters/UpdateTeamPresenter'; // Tokens import { LOGGER_TOKEN } from './TeamProviders'; @@ -47,125 +42,150 @@ export class TeamService { @Inject(LOGGER_TOKEN) private readonly logger: Logger, ) {} - async getAll(): Promise { + async getAll(): Promise { this.logger.debug('[TeamService] Fetching all teams.'); const presenter = new AllTeamsPresenter(); const result = await this.getAllTeamsUseCase.execute(); if (result.isErr()) { this.logger.error('Error fetching all teams', result.error); - return { teams: [], totalCount: 0 }; + await presenter.present({ teams: [], totalCount: 0 }); + return presenter; } + await presenter.present(result.value); - return presenter.getViewModel()!; + return presenter; } - async getDetails(teamId: string, userId?: string): Promise { + async getDetails(teamId: string, userId?: string): Promise { this.logger.debug(`[TeamService] Fetching team details for teamId: ${teamId}, userId: ${userId}`); const presenter = new TeamDetailsPresenter(); const result = await this.getTeamDetailsUseCase.execute({ teamId, driverId: userId || '' }); if (result.isErr()) { this.logger.error(`Error fetching team details for teamId: ${teamId}`, result.error); - return null; + return presenter; } + await presenter.present(result.value); - return presenter.getViewModel(); + return presenter; } - async getMembers(teamId: string): Promise { + async getMembers(teamId: string): Promise { this.logger.debug(`[TeamService] Fetching team members for teamId: ${teamId}`); const presenter = new TeamMembersPresenter(); const result = await this.getTeamMembersUseCase.execute({ teamId }); if (result.isErr()) { this.logger.error(`Error fetching team members for teamId: ${teamId}`, result.error); - return { + await presenter.present({ members: [], totalCount: 0, ownerCount: 0, managerCount: 0, memberCount: 0, - }; + } as unknown as any); + return presenter; } + await presenter.present(result.value); - return presenter.getViewModel()!; + return presenter; } - async getJoinRequests(teamId: string): Promise { + async getJoinRequests(teamId: string): Promise { this.logger.debug(`[TeamService] Fetching team join requests for teamId: ${teamId}`); const presenter = new TeamJoinRequestsPresenter(); const result = await this.getTeamJoinRequestsUseCase.execute({ teamId }); if (result.isErr()) { this.logger.error(`Error fetching team join requests for teamId: ${teamId}`, result.error); - return { + await presenter.present({ requests: [], pendingCount: 0, totalCount: 0, - }; + } as unknown as any); + return presenter; } + await presenter.present(result.value); - return presenter.getViewModel()!; + return presenter; } - async create(input: CreateTeamInputDTO, userId?: string): Promise { + async create(input: CreateTeamInputDTO, userId?: string): Promise { this.logger.debug('[TeamService] Creating team', { input, userId }); + const presenter = new CreateTeamPresenter(); + const command = { name: input.name, tag: input.tag, - description: input.description, + description: input.description ?? '', ownerId: userId || '', + leagues: [], }; - const result = await this.createTeamUseCase.execute(command); + + const result = await this.createTeamUseCase.execute(command as any); if (result.isErr()) { this.logger.error('Error creating team', result.error); - return { id: '', success: false }; + presenter.presentError(); + return presenter; } - return { id: result.value.id, success: true }; + + presenter.presentSuccess(result.value); + return presenter; } - async update(teamId: string, input: UpdateTeamInputDTO, userId?: string): Promise { + async update(teamId: string, input: UpdateTeamInputDTO, userId?: string): Promise { this.logger.debug(`[TeamService] Updating team ${teamId}`, { input, userId }); + const presenter = new UpdateTeamPresenter(); + const command = { teamId, - name: input.name, - tag: input.tag, - description: input.description, - performerId: userId || '', + updates: { + name: input.name, + tag: input.tag, + description: input.description, + }, + updatedBy: userId || '', }; - const result = await this.updateTeamUseCase.execute(command); + + const result = await this.updateTeamUseCase.execute(command as any); if (result.isErr()) { this.logger.error(`Error updating team ${teamId}`, result.error); - return { success: false }; + presenter.presentError(); + return presenter; } - return { success: true }; + + presenter.presentSuccess(); + return presenter; } - async getDriverTeam(driverId: string): Promise { + async getDriverTeam(driverId: string): Promise { this.logger.debug(`[TeamService] Fetching driver team for driverId: ${driverId}`); + const presenter = new DriverTeamPresenter(); const result = await this.getDriverTeamUseCase.execute({ driverId }); if (result.isErr()) { this.logger.error(`Error fetching driver team for driverId: ${driverId}`, result.error); - return null; + return presenter; } - const presenter = new DriverTeamPresenter(); await presenter.present(result.value); - return presenter.getViewModel(); + return presenter; } - async getMembership(teamId: string, driverId: string): Promise { + async getMembership(teamId: string, driverId: string): Promise { this.logger.debug(`[TeamService] Fetching team membership for teamId: ${teamId}, driverId: ${driverId}`); + const presenter = new TeamMembershipPresenter(); const result = await this.getTeamMembershipUseCase.execute({ teamId, driverId }); if (result.isErr()) { this.logger.error(`Error fetching team membership for teamId: ${teamId}, driverId: ${driverId}`, result.error); - return null; + return presenter; } - return result.value; + + presenter.present(result.value as any); + return presenter; } } \ No newline at end of file diff --git a/apps/api/src/domain/team/presenters/CreateTeamPresenter.ts b/apps/api/src/domain/team/presenters/CreateTeamPresenter.ts new file mode 100644 index 000000000..df1c0d951 --- /dev/null +++ b/apps/api/src/domain/team/presenters/CreateTeamPresenter.ts @@ -0,0 +1,36 @@ +import type { CreateTeamOutputPort } from '@core/racing/application/ports/output/CreateTeamOutputPort'; +import type { CreateTeamOutputDTO } from '../dtos/CreateTeamOutputDTO'; + +export class CreateTeamPresenter { + private result: CreateTeamOutputDTO | null = null; + + reset(): void { + this.result = null; + } + + presentSuccess(output: CreateTeamOutputPort): void { + this.result = { + id: output.team.id, + success: true, + }; + } + + presentError(): void { + this.result = { + id: '', + success: false, + }; + } + + getViewModel(): CreateTeamOutputDTO | null { + return this.result; + } + + get viewModel(): CreateTeamOutputDTO { + if (!this.result) { + throw new Error('Presenter not presented'); + } + + return this.result; + } +} diff --git a/apps/api/src/domain/team/presenters/TeamMembershipPresenter.ts b/apps/api/src/domain/team/presenters/TeamMembershipPresenter.ts new file mode 100644 index 000000000..ae35cd317 --- /dev/null +++ b/apps/api/src/domain/team/presenters/TeamMembershipPresenter.ts @@ -0,0 +1,30 @@ +import type { GetTeamMembershipOutputDTO } from '../dtos/GetTeamMembershipOutputDTO'; + +export class TeamMembershipPresenter { + private result: GetTeamMembershipOutputDTO | null = null; + + reset(): void { + this.result = null; + } + + present(membership: GetTeamMembershipOutputDTO | null): void { + if (!membership) { + this.result = null; + return; + } + + this.result = { + role: membership.role, + joinedAt: membership.joinedAt, + isActive: membership.isActive, + }; + } + + getViewModel(): GetTeamMembershipOutputDTO | null { + return this.result; + } + + get viewModel(): GetTeamMembershipOutputDTO | null { + return this.result; + } +} diff --git a/apps/api/src/domain/team/presenters/UpdateTeamPresenter.ts b/apps/api/src/domain/team/presenters/UpdateTeamPresenter.ts new file mode 100644 index 000000000..d8d53523e --- /dev/null +++ b/apps/api/src/domain/team/presenters/UpdateTeamPresenter.ts @@ -0,0 +1,33 @@ +import type { UpdateTeamOutputDTO } from '../dtos/UpdateTeamOutputDTO'; + +export class UpdateTeamPresenter { + private result: UpdateTeamOutputDTO | null = null; + + reset(): void { + this.result = null; + } + + presentSuccess(): void { + this.result = { + success: true, + }; + } + + presentError(): void { + this.result = { + success: false, + }; + } + + getViewModel(): UpdateTeamOutputDTO | null { + return this.result; + } + + get viewModel(): UpdateTeamOutputDTO { + if (!this.result) { + throw new Error('Presenter not presented'); + } + + return this.result; + } +} diff --git a/apps/api/src/presentation/hello.controller.ts b/apps/api/src/presentation/hello.controller.ts index 1084f4575..a72a10126 100644 --- a/apps/api/src/presentation/hello.controller.ts +++ b/apps/api/src/presentation/hello.controller.ts @@ -1,4 +1,5 @@ + import { Controller, Get } from '@nestjs/common'; import { HelloService } from '../application/hello/hello.service'; @@ -7,7 +8,8 @@ export class HelloController { constructor(private readonly helloService: HelloService) {} @Get() - getHello(): string { - return this.helloService.getHello(); + getHello(): { message: string } { + const presenter = this.helloService.getHello(); + return presenter.viewModel; } -} +} \ No newline at end of file diff --git a/apps/website/next.config.mjs b/apps/website/next.config.mjs index 342819565..69ab32446 100644 --- a/apps/website/next.config.mjs +++ b/apps/website/next.config.mjs @@ -3,7 +3,7 @@ const nextConfig = { reactStrictMode: true, // Fix for Next.js 13+ Turbopack in monorepos to correctly identify the workspace root - outputFileTracingRoot: '../../', + outputFileTracingRoot: '/Users/marcmintel/Projects/gridpilot', images: { remotePatterns: [ {