diff --git a/apps/api/src/application/hello/hello.service.test.ts b/apps/api/src/application/hello/hello.service.test.ts index 038476b8b..b7e4e3079 100644 --- a/apps/api/src/application/hello/hello.service.test.ts +++ b/apps/api/src/application/hello/hello.service.test.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { HelloService } from './hello.service'; +import { HelloPresenter } from './presenters/HelloPresenter'; describe('HelloService', () => { let service: HelloService; @@ -19,6 +20,6 @@ describe('HelloService', () => { it('should return "Hello World!"', () => { const presenter = service.getHello(); - expect(presenter.viewModel).toEqual({ message: 'Hello World!' }); + expect(presenter.responseModel).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 b99f25e07..22c75b24f 100644 --- a/apps/api/src/application/hello/hello.service.ts +++ b/apps/api/src/application/hello/hello.service.ts @@ -1,13 +1,16 @@ import { Injectable } from '@nestjs/common'; -import { HelloPresenter } from './presenters/HelloPresenter'; +import { Result } from '@core/shared/application/Result'; +import { HelloPresenter, HelloResponseModel } from './presenters/HelloPresenter'; @Injectable() export class HelloService { - getHello(): HelloPresenter { - const presenter = new HelloPresenter(); - presenter.present('Hello World!'); - return presenter; + constructor(private readonly presenter: HelloPresenter) {} + + getHello(): HelloResponseModel { + const result = Result.ok('Hello World!'); + this.presenter.present(result); + return this.presenter.responseModel; } } \ 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 index b2890c063..da4aaa333 100644 --- a/apps/api/src/application/hello/presenters/HelloPresenter.ts +++ b/apps/api/src/application/hello/presenters/HelloPresenter.ts @@ -1,19 +1,25 @@ -export interface HelloViewModel { +import type { Result } from '@core/shared/application/Result'; + +export interface HelloResponseModel { message: string; } export class HelloPresenter { - private result: HelloViewModel | null = null; + private result: HelloResponseModel | null = null; reset(): void { this.result = null; } - present(message: string): void { + present(result: Result): void { + if (result.isErr()) { + throw result.unwrapErr(); + } + const message = result.unwrap(); this.result = { message }; } - get viewModel(): HelloViewModel { + get responseModel(): HelloResponseModel { if (!this.result) { throw new Error('HelloPresenter not presented'); } diff --git a/apps/api/src/domain/analytics/AnalyticsController.test.ts b/apps/api/src/domain/analytics/AnalyticsController.test.ts index 1c11b942c..e15a175d7 100644 --- a/apps/api/src/domain/analytics/AnalyticsController.test.ts +++ b/apps/api/src/domain/analytics/AnalyticsController.test.ts @@ -5,6 +5,8 @@ import { AnalyticsService } from './AnalyticsService'; import type { Response } from 'express'; import { EntityType, VisitorType } from '@core/analytics/domain/types/PageView'; import { EngagementAction, EngagementEntityType } from '@core/analytics/domain/types/EngagementEvent'; +import type { RecordEngagementOutputDTO } from './dtos/RecordEngagementOutputDTO'; +import type { RecordPageViewOutputDTO } from './dtos/RecordPageViewOutputDTO'; describe('AnalyticsController', () => { let controller: AnalyticsController; @@ -42,8 +44,8 @@ describe('AnalyticsController', () => { userAgent: 'Mozilla/5.0', country: 'US', }; - const presenterMock = { viewModel: { pageViewId: 'pv-123' } }; - service.recordPageView.mockResolvedValue(presenterMock as any); + const dto: RecordPageViewOutputDTO = { pageViewId: 'pv-123' }; + service.recordPageView.mockResolvedValue(dto); const mockRes: ReturnType> = { status: vi.fn().mockReturnThis(), @@ -54,7 +56,7 @@ describe('AnalyticsController', () => { expect(service.recordPageView).toHaveBeenCalledWith(input); expect(mockRes.status).toHaveBeenCalledWith(201); - expect(mockRes.json).toHaveBeenCalledWith(presenterMock.viewModel); + expect(mockRes.json).toHaveBeenCalledWith(dto); }); }); @@ -69,8 +71,8 @@ describe('AnalyticsController', () => { actorId: 'actor-789', metadata: { key: 'value' }, }; - const presenterMock = { eventId: 'event-123', engagementWeight: 10 }; - service.recordEngagement.mockResolvedValue(presenterMock as any); + const dto: RecordEngagementOutputDTO = { eventId: 'event-123', engagementWeight: 10 }; + service.recordEngagement.mockResolvedValue(dto); const mockRes: ReturnType> = { status: vi.fn().mockReturnThis(), @@ -81,45 +83,41 @@ describe('AnalyticsController', () => { expect(service.recordEngagement).toHaveBeenCalledWith(input); expect(mockRes.status).toHaveBeenCalledWith(201); - expect(mockRes.json).toHaveBeenCalledWith((presenterMock as any).viewModel); + expect(mockRes.json).toHaveBeenCalledWith(dto); }); }); describe('getDashboardData', () => { it('should return dashboard data', async () => { - const presenterMock = { - viewModel: { - totalUsers: 100, - activeUsers: 50, - totalRaces: 20, - totalLeagues: 5, - }, + const dto = { + totalUsers: 100, + activeUsers: 50, + totalRaces: 20, + totalLeagues: 5, }; - service.getDashboardData.mockResolvedValue(presenterMock as any); + service.getDashboardData.mockResolvedValue(dto); const result = await controller.getDashboardData(); expect(service.getDashboardData).toHaveBeenCalled(); - expect(result).toEqual(presenterMock.viewModel); + expect(result).toEqual(dto); }); }); describe('getAnalyticsMetrics', () => { it('should return analytics metrics', async () => { - const presenterMock = { - viewModel: { - pageViews: 1000, - uniqueVisitors: 500, - averageSessionDuration: 300, - bounceRate: 0.4, - }, + const dto = { + pageViews: 1000, + uniqueVisitors: 500, + averageSessionDuration: 300, + bounceRate: 0.4, }; - service.getAnalyticsMetrics.mockResolvedValue(presenterMock as any); + service.getAnalyticsMetrics.mockResolvedValue(dto); const result = await controller.getAnalyticsMetrics(); expect(service.getAnalyticsMetrics).toHaveBeenCalled(); - expect(result).toEqual(presenterMock.viewModel); + expect(result).toEqual(dto); }); }); }); \ 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 612ef2c21..efd2c7c49 100644 --- a/apps/api/src/domain/analytics/AnalyticsController.ts +++ b/apps/api/src/domain/analytics/AnalyticsController.ts @@ -27,8 +27,8 @@ export class AnalyticsController { @Body() input: RecordPageViewInput, @Res() res: Response, ): Promise { - const presenter = await this.analyticsService.recordPageView(input); - res.status(HttpStatus.CREATED).json(presenter.viewModel); + const dto = await this.analyticsService.recordPageView(input); + res.status(HttpStatus.CREATED).json(dto); } @Post('engagement') @@ -39,23 +39,21 @@ export class AnalyticsController { @Body() input: RecordEngagementInput, @Res() res: Response, ): Promise { - const presenter = await this.analyticsService.recordEngagement(input); - res.status(HttpStatus.CREATED).json(presenter.viewModel); + const dto = await this.analyticsService.recordEngagement(input); + res.status(HttpStatus.CREATED).json(dto); } @Get('dashboard') @ApiOperation({ summary: 'Get analytics dashboard data' }) @ApiResponse({ status: 200, description: 'Dashboard data', type: GetDashboardDataOutputDTO }) async getDashboardData(): Promise { - const presenter = await this.analyticsService.getDashboardData(); - return presenter.viewModel; + return this.analyticsService.getDashboardData(); } @Get('metrics') @ApiOperation({ summary: 'Get analytics metrics' }) @ApiResponse({ status: 200, description: 'Analytics metrics', type: GetAnalyticsMetricsOutputDTO }) async getAnalyticsMetrics(): Promise { - const presenter = await this.analyticsService.getAnalyticsMetrics(); - return presenter.viewModel; + return this.analyticsService.getAnalyticsMetrics(); } } diff --git a/apps/api/src/domain/analytics/AnalyticsProviders.ts b/apps/api/src/domain/analytics/AnalyticsProviders.ts index ccc994565..2ceb1336a 100644 --- a/apps/api/src/domain/analytics/AnalyticsProviders.ts +++ b/apps/api/src/domain/analytics/AnalyticsProviders.ts @@ -1,27 +1,40 @@ import { Provider } from '@nestjs/common'; import { AnalyticsService } from './AnalyticsService'; -import { RecordPageViewUseCase } from '@core/analytics/application/use-cases/RecordPageViewUseCase'; -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 type { IPageViewRepository } from '@core/analytics/domain/repositories/IPageViewRepository'; import type { IEngagementRepository } from '@core/analytics/domain/repositories/IEngagementRepository'; -import type { Logger } from '@core/shared/application'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import type { RecordPageViewOutput } from '@core/analytics/application/use-cases/RecordPageViewUseCase'; +import type { RecordEngagementOutput } from '@core/analytics/application/use-cases/RecordEngagementUseCase'; +import type { GetDashboardDataOutput } from '@core/analytics/application/use-cases/GetDashboardDataUseCase'; +import type { GetAnalyticsMetricsOutput } from '@core/analytics/application/use-cases/GetAnalyticsMetricsUseCase'; const Logger_TOKEN = 'Logger_TOKEN'; const IPAGE_VIEW_REPO_TOKEN = 'IPageViewRepository_TOKEN'; const IENGAGEMENT_REPO_TOKEN = 'IEngagementRepository_TOKEN'; -const RECORD_PAGE_VIEW_USE_CASE_TOKEN = 'RecordPageViewUseCase_TOKEN'; -const RECORD_ENGAGEMENT_USE_CASE_TOKEN = 'RecordEngagementUseCase_TOKEN'; -const GET_DASHBOARD_DATA_USE_CASE_TOKEN = 'GetDashboardDataUseCase_TOKEN'; -const GET_ANALYTICS_METRICS_USE_CASE_TOKEN = 'GetAnalyticsMetricsUseCase_TOKEN'; + +const RECORD_PAGE_VIEW_OUTPUT_PORT_TOKEN = 'RecordPageViewOutputPort_TOKEN'; +const RECORD_ENGAGEMENT_OUTPUT_PORT_TOKEN = 'RecordEngagementOutputPort_TOKEN'; +const GET_DASHBOARD_DATA_OUTPUT_PORT_TOKEN = 'GetDashboardDataOutputPort_TOKEN'; +const GET_ANALYTICS_METRICS_OUTPUT_PORT_TOKEN = 'GetAnalyticsMetricsOutputPort_TOKEN'; import { InMemoryEngagementRepository } from '@adapters/analytics/persistence/inmemory/InMemoryEngagementRepository'; import { InMemoryPageViewRepository } from '@adapters/analytics/persistence/inmemory/InMemoryPageViewRepository'; import { ConsoleLogger } from '@adapters/logging/ConsoleLogger'; +import { RecordPageViewUseCase } from '@core/analytics/application/use-cases/RecordPageViewUseCase'; +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'; export const AnalyticsProviders: Provider[] = [ AnalyticsService, + RecordPageViewPresenter, + RecordEngagementPresenter, + GetDashboardDataPresenter, + GetAnalyticsMetricsPresenter, { provide: Logger_TOKEN, useClass: ConsoleLogger, @@ -35,23 +48,43 @@ export const AnalyticsProviders: Provider[] = [ useClass: InMemoryEngagementRepository, }, { - provide: RECORD_PAGE_VIEW_USE_CASE_TOKEN, - useFactory: (repo: IPageViewRepository, logger: Logger) => new RecordPageViewUseCase(repo, logger), - inject: [IPAGE_VIEW_REPO_TOKEN, Logger_TOKEN], + provide: RECORD_PAGE_VIEW_OUTPUT_PORT_TOKEN, + useExisting: RecordPageViewPresenter, }, { - provide: RECORD_ENGAGEMENT_USE_CASE_TOKEN, - useFactory: (repo: IEngagementRepository, logger: Logger) => new RecordEngagementUseCase(repo, logger), - inject: [IENGAGEMENT_REPO_TOKEN, Logger_TOKEN], + provide: RECORD_ENGAGEMENT_OUTPUT_PORT_TOKEN, + useExisting: RecordEngagementPresenter, }, { - provide: GET_DASHBOARD_DATA_USE_CASE_TOKEN, - useFactory: (logger: Logger) => new GetDashboardDataUseCase(logger), - inject: [Logger_TOKEN], + provide: GET_DASHBOARD_DATA_OUTPUT_PORT_TOKEN, + useExisting: GetDashboardDataPresenter, }, { - provide: GET_ANALYTICS_METRICS_USE_CASE_TOKEN, - useFactory: (repo: IPageViewRepository, logger: Logger) => new GetAnalyticsMetricsUseCase(repo, logger), - inject: [IPAGE_VIEW_REPO_TOKEN, Logger_TOKEN], + provide: GET_ANALYTICS_METRICS_OUTPUT_PORT_TOKEN, + useExisting: GetAnalyticsMetricsPresenter, + }, + { + provide: RecordPageViewUseCase, + useFactory: (repo: IPageViewRepository, logger: Logger, output: UseCaseOutputPort) => + new RecordPageViewUseCase(repo, logger, output), + inject: [IPAGE_VIEW_REPO_TOKEN, Logger_TOKEN, RECORD_PAGE_VIEW_OUTPUT_PORT_TOKEN], + }, + { + provide: RecordEngagementUseCase, + useFactory: (repo: IEngagementRepository, logger: Logger, output: UseCaseOutputPort) => + new RecordEngagementUseCase(repo, logger, output), + inject: [IENGAGEMENT_REPO_TOKEN, Logger_TOKEN, RECORD_ENGAGEMENT_OUTPUT_PORT_TOKEN], + }, + { + provide: GetDashboardDataUseCase, + useFactory: (logger: Logger, output: UseCaseOutputPort) => + new GetDashboardDataUseCase(logger, output), + inject: [Logger_TOKEN, GET_DASHBOARD_DATA_OUTPUT_PORT_TOKEN], + }, + { + provide: GetAnalyticsMetricsUseCase, + useFactory: (repo: IPageViewRepository, logger: Logger, output: UseCaseOutputPort) => + new GetAnalyticsMetricsUseCase(repo, logger, output), + inject: [IPAGE_VIEW_REPO_TOKEN, Logger_TOKEN, GET_ANALYTICS_METRICS_OUTPUT_PORT_TOKEN], }, ]; \ No newline at end of file diff --git a/apps/api/src/domain/analytics/AnalyticsService.ts b/apps/api/src/domain/analytics/AnalyticsService.ts index eb71fbdd4..61912c543 100644 --- a/apps/api/src/domain/analytics/AnalyticsService.ts +++ b/apps/api/src/domain/analytics/AnalyticsService.ts @@ -17,41 +17,52 @@ import { GetAnalyticsMetricsPresenter } from './presenters/GetAnalyticsMetricsPr type RecordPageViewInput = RecordPageViewInputDTO; type RecordEngagementInput = RecordEngagementInputDTO; -const RECORD_PAGE_VIEW_USE_CASE_TOKEN = 'RecordPageViewUseCase_TOKEN'; -const RECORD_ENGAGEMENT_USE_CASE_TOKEN = 'RecordEngagementUseCase_TOKEN'; -const GET_DASHBOARD_DATA_USE_CASE_TOKEN = 'GetDashboardDataUseCase_TOKEN'; -const GET_ANALYTICS_METRICS_USE_CASE_TOKEN = 'GetAnalyticsMetricsUseCase_TOKEN'; - @Injectable() export class AnalyticsService { constructor( - @Inject(RECORD_PAGE_VIEW_USE_CASE_TOKEN) private readonly recordPageViewUseCase: RecordPageViewUseCase, - @Inject(RECORD_ENGAGEMENT_USE_CASE_TOKEN) private readonly recordEngagementUseCase: RecordEngagementUseCase, - @Inject(GET_DASHBOARD_DATA_USE_CASE_TOKEN) private readonly getDashboardDataUseCase: GetDashboardDataUseCase, - @Inject(GET_ANALYTICS_METRICS_USE_CASE_TOKEN) private readonly getAnalyticsMetricsUseCase: GetAnalyticsMetricsUseCase, + @Inject(RecordPageViewUseCase) private readonly recordPageViewUseCase: RecordPageViewUseCase, + @Inject(RecordEngagementUseCase) private readonly recordEngagementUseCase: RecordEngagementUseCase, + @Inject(GetDashboardDataUseCase) private readonly getDashboardDataUseCase: GetDashboardDataUseCase, + @Inject(GetAnalyticsMetricsUseCase) private readonly getAnalyticsMetricsUseCase: GetAnalyticsMetricsUseCase, + private readonly recordPageViewPresenter: RecordPageViewPresenter, + private readonly recordEngagementPresenter: RecordEngagementPresenter, + private readonly getDashboardDataPresenter: GetDashboardDataPresenter, + private readonly getAnalyticsMetricsPresenter: GetAnalyticsMetricsPresenter, ) {} - async recordPageView(input: RecordPageViewInput): Promise { - const presenter = new RecordPageViewPresenter(); - await this.recordPageViewUseCase.execute(input, presenter); - return presenter; + async recordPageView(input: RecordPageViewInput): Promise { + const result = await this.recordPageViewUseCase.execute(input); + if (result.isErr()) { + const error = result.unwrapErr(); + throw new Error(error.details?.message ?? 'Failed to record page view'); + } + return this.recordPageViewPresenter.getResponseModel(); } - async recordEngagement(input: RecordEngagementInput): Promise { - const presenter = new RecordEngagementPresenter(); - await this.recordEngagementUseCase.execute(input, presenter); - return presenter; + async recordEngagement(input: RecordEngagementInput): Promise { + const result = await this.recordEngagementUseCase.execute(input); + if (result.isErr()) { + const error = result.unwrapErr(); + throw new Error(error.details?.message ?? 'Failed to record engagement'); + } + return this.recordEngagementPresenter.getResponseModel(); } - async getDashboardData(): Promise { - const presenter = new GetDashboardDataPresenter(); - await this.getDashboardDataUseCase.execute(undefined, presenter); - return presenter; + async getDashboardData(): Promise { + const result = await this.getDashboardDataUseCase.execute({}); + if (result.isErr()) { + const error = result.unwrapErr(); + throw new Error(error.details?.message ?? 'Failed to get dashboard data'); + } + return this.getDashboardDataPresenter.getResponseModel(); } - async getAnalyticsMetrics(): Promise { - const presenter = new GetAnalyticsMetricsPresenter(); - await this.getAnalyticsMetricsUseCase.execute(undefined, presenter); - return presenter; + async getAnalyticsMetrics(): Promise { + const result = await this.getAnalyticsMetricsUseCase.execute({}); + if (result.isErr()) { + const error = result.unwrapErr(); + throw new Error(error.details?.message ?? 'Failed to get analytics metrics'); + } + return this.getAnalyticsMetricsPresenter.getResponseModel(); } } diff --git a/apps/api/src/domain/analytics/presenters/GetAnalyticsMetricsPresenter.test.ts b/apps/api/src/domain/analytics/presenters/GetAnalyticsMetricsPresenter.test.ts index 7e4a13140..80fdb37f0 100644 --- a/apps/api/src/domain/analytics/presenters/GetAnalyticsMetricsPresenter.test.ts +++ b/apps/api/src/domain/analytics/presenters/GetAnalyticsMetricsPresenter.test.ts @@ -9,7 +9,7 @@ describe('GetAnalyticsMetricsPresenter', () => { presenter = new GetAnalyticsMetricsPresenter(); }); - it('maps use case output to DTO correctly', () => { + it('maps output to DTO correctly', () => { const output: GetAnalyticsMetricsOutput = { pageViews: 1000, uniqueVisitors: 500, @@ -19,7 +19,9 @@ describe('GetAnalyticsMetricsPresenter', () => { presenter.present(output); - expect(presenter.viewModel).toEqual({ + const dto = presenter.getResponseModel(); + + expect(dto).toEqual({ pageViews: 1000, uniqueVisitors: 500, averageSessionDuration: 300, @@ -27,19 +29,7 @@ describe('GetAnalyticsMetricsPresenter', () => { }); }); - 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'); + it('getResponseModel throws if not presented', () => { + expect(() => presenter.getResponseModel()).toThrow('Presenter not presented'); }); }); diff --git a/apps/api/src/domain/analytics/presenters/GetAnalyticsMetricsPresenter.ts b/apps/api/src/domain/analytics/presenters/GetAnalyticsMetricsPresenter.ts index 6afc09400..4b1a1e8ff 100644 --- a/apps/api/src/domain/analytics/presenters/GetAnalyticsMetricsPresenter.ts +++ b/apps/api/src/domain/analytics/presenters/GetAnalyticsMetricsPresenter.ts @@ -1,24 +1,21 @@ +import type { UseCaseOutputPort } from '@core/shared/application'; import type { GetAnalyticsMetricsOutput } from '@core/analytics/application/use-cases/GetAnalyticsMetricsUseCase'; import type { GetAnalyticsMetricsOutputDTO } from '../dtos/GetAnalyticsMetricsOutputDTO'; -export class GetAnalyticsMetricsPresenter { - private result: GetAnalyticsMetricsOutputDTO | null = null; +export class GetAnalyticsMetricsPresenter implements UseCaseOutputPort { + private responseModel: 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, + present(result: GetAnalyticsMetricsOutput): void { + this.responseModel = { + pageViews: result.pageViews, + uniqueVisitors: result.uniqueVisitors, + averageSessionDuration: result.averageSessionDuration, + bounceRate: result.bounceRate, }; } - get viewModel(): GetAnalyticsMetricsOutputDTO { - if (!this.result) throw new Error('Presenter not presented'); - return this.result; + getResponseModel(): GetAnalyticsMetricsOutputDTO { + if (!this.responseModel) throw new Error('Presenter not presented'); + return this.responseModel; } } diff --git a/apps/api/src/domain/analytics/presenters/GetDashboardDataPresenter.test.ts b/apps/api/src/domain/analytics/presenters/GetDashboardDataPresenter.test.ts index bb541444f..f4ba3e64b 100644 --- a/apps/api/src/domain/analytics/presenters/GetDashboardDataPresenter.test.ts +++ b/apps/api/src/domain/analytics/presenters/GetDashboardDataPresenter.test.ts @@ -9,7 +9,7 @@ describe('GetDashboardDataPresenter', () => { presenter = new GetDashboardDataPresenter(); }); - it('maps use case output to DTO correctly', () => { + it('maps output to DTO correctly', () => { const output: GetDashboardDataOutput = { totalUsers: 100, activeUsers: 50, @@ -19,7 +19,7 @@ describe('GetDashboardDataPresenter', () => { presenter.present(output); - expect(presenter.viewModel).toEqual({ + expect(presenter.getResponseModel()).toEqual({ totalUsers: 100, activeUsers: 50, totalRaces: 20, @@ -27,19 +27,7 @@ describe('GetDashboardDataPresenter', () => { }); }); - 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'); + it('getResponseModel throws if not presented', () => { + expect(() => presenter.getResponseModel()).toThrow('Presenter not presented'); }); }); diff --git a/apps/api/src/domain/analytics/presenters/GetDashboardDataPresenter.ts b/apps/api/src/domain/analytics/presenters/GetDashboardDataPresenter.ts index 2622f964c..0233ceb4d 100644 --- a/apps/api/src/domain/analytics/presenters/GetDashboardDataPresenter.ts +++ b/apps/api/src/domain/analytics/presenters/GetDashboardDataPresenter.ts @@ -1,24 +1,21 @@ +import type { UseCaseOutputPort } from '@core/shared/application'; import type { GetDashboardDataOutput } from '@core/analytics/application/use-cases/GetDashboardDataUseCase'; import type { GetDashboardDataOutputDTO } from '../dtos/GetDashboardDataOutputDTO'; -export class GetDashboardDataPresenter { - private result: GetDashboardDataOutputDTO | null = null; +export class GetDashboardDataPresenter implements UseCaseOutputPort { + private responseModel: 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, + present(result: GetDashboardDataOutput): void { + this.responseModel = { + totalUsers: result.totalUsers, + activeUsers: result.activeUsers, + totalRaces: result.totalRaces, + totalLeagues: result.totalLeagues, }; } - get viewModel(): GetDashboardDataOutputDTO { - if (!this.result) throw new Error('Presenter not presented'); - return this.result; + getResponseModel(): GetDashboardDataOutputDTO { + if (!this.responseModel) throw new Error('Presenter not presented'); + return this.responseModel; } } diff --git a/apps/api/src/domain/analytics/presenters/RecordEngagementPresenter.test.ts b/apps/api/src/domain/analytics/presenters/RecordEngagementPresenter.test.ts index 2ecc6e9ea..dc251d4a1 100644 --- a/apps/api/src/domain/analytics/presenters/RecordEngagementPresenter.test.ts +++ b/apps/api/src/domain/analytics/presenters/RecordEngagementPresenter.test.ts @@ -9,31 +9,21 @@ describe('RecordEngagementPresenter', () => { presenter = new RecordEngagementPresenter(); }); - it('maps use case output to DTO correctly', () => { + it('maps output to DTO correctly', () => { const output: RecordEngagementOutput = { eventId: 'event-123', engagementWeight: 10, - } as RecordEngagementOutput; + }; presenter.present(output); - expect(presenter.viewModel).toEqual({ + expect(presenter.getResponseModel()).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'); + it('getResponseModel throws if not presented', () => { + expect(() => presenter.getResponseModel()).toThrow('Presenter not presented'); }); }); diff --git a/apps/api/src/domain/analytics/presenters/RecordEngagementPresenter.ts b/apps/api/src/domain/analytics/presenters/RecordEngagementPresenter.ts index 42ce3739f..cadb62bc4 100644 --- a/apps/api/src/domain/analytics/presenters/RecordEngagementPresenter.ts +++ b/apps/api/src/domain/analytics/presenters/RecordEngagementPresenter.ts @@ -1,22 +1,19 @@ +import type { UseCaseOutputPort } from '@core/shared/application'; import type { RecordEngagementOutput } from '@core/analytics/application/use-cases/RecordEngagementUseCase'; import type { RecordEngagementOutputDTO } from '../dtos/RecordEngagementOutputDTO'; -export class RecordEngagementPresenter { - private result: RecordEngagementOutputDTO | null = null; +export class RecordEngagementPresenter implements UseCaseOutputPort { + private responseModel: RecordEngagementOutputDTO | null = null; - reset() { - this.result = null; - } - - present(output: RecordEngagementOutput): void { - this.result = { - eventId: output.eventId, - engagementWeight: output.engagementWeight, + present(result: RecordEngagementOutput): void { + this.responseModel = { + eventId: result.eventId, + engagementWeight: result.engagementWeight, }; } - get viewModel(): RecordEngagementOutputDTO { - if (!this.result) throw new Error('Presenter not presented'); - return this.result; + getResponseModel(): RecordEngagementOutputDTO { + if (!this.responseModel) throw new Error('Presenter not presented'); + return this.responseModel; } } diff --git a/apps/api/src/domain/analytics/presenters/RecordPageViewPresenter.test.ts b/apps/api/src/domain/analytics/presenters/RecordPageViewPresenter.test.ts index 31c4d5ef9..81d0a524c 100644 --- a/apps/api/src/domain/analytics/presenters/RecordPageViewPresenter.test.ts +++ b/apps/api/src/domain/analytics/presenters/RecordPageViewPresenter.test.ts @@ -9,28 +9,19 @@ describe('RecordPageViewPresenter', () => { presenter = new RecordPageViewPresenter(); }); - it('maps use case output to DTO correctly', () => { + it('maps output to DTO correctly', () => { const output: RecordPageViewOutput = { pageViewId: 'pv-123', - } as RecordPageViewOutput; + }; presenter.present(output); - expect(presenter.viewModel).toEqual({ + expect(presenter.getResponseModel()).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'); + it('getResponseModel throws if not presented', () => { + expect(() => presenter.getResponseModel()).toThrow('Presenter not presented'); }); }); diff --git a/apps/api/src/domain/analytics/presenters/RecordPageViewPresenter.ts b/apps/api/src/domain/analytics/presenters/RecordPageViewPresenter.ts index bdd6c5e6a..b60fd1a14 100644 --- a/apps/api/src/domain/analytics/presenters/RecordPageViewPresenter.ts +++ b/apps/api/src/domain/analytics/presenters/RecordPageViewPresenter.ts @@ -1,21 +1,18 @@ +import type { UseCaseOutputPort } from '@core/shared/application'; import type { RecordPageViewOutput } from '@core/analytics/application/use-cases/RecordPageViewUseCase'; import type { RecordPageViewOutputDTO } from '../dtos/RecordPageViewOutputDTO'; -export class RecordPageViewPresenter { - private result: RecordPageViewOutputDTO | null = null; +export class RecordPageViewPresenter implements UseCaseOutputPort { + private responseModel: RecordPageViewOutputDTO | null = null; - reset() { - this.result = null; - } - - present(output: RecordPageViewOutput): void { - this.result = { - pageViewId: output.pageViewId, + present(result: RecordPageViewOutput): void { + this.responseModel = { + pageViewId: result.pageViewId, }; } - get viewModel(): RecordPageViewOutputDTO { - if (!this.result) throw new Error('Presenter not presented'); - return this.result; + getResponseModel(): RecordPageViewOutputDTO { + if (!this.responseModel) throw new Error('Presenter not presented'); + return this.responseModel; } } diff --git a/apps/api/src/domain/auth/AuthController.test.ts b/apps/api/src/domain/auth/AuthController.test.ts index 8ccd6ef30..2426c06a6 100644 --- a/apps/api/src/domain/auth/AuthController.test.ts +++ b/apps/api/src/domain/auth/AuthController.test.ts @@ -5,21 +5,21 @@ import { SignupParams, LoginParams, AuthSessionDTO } from './dtos/AuthDto'; describe('AuthController', () => { let controller: AuthController; - let service: ReturnType>; + let service: AuthService; beforeEach(() => { - service = vi.mocked({ + service = { signupWithEmail: vi.fn(), loginWithEmail: vi.fn(), getCurrentSession: vi.fn(), logout: vi.fn(), - }); + } as unknown as AuthService; controller = new AuthController(service); }); describe('signup', () => { - it('should call service.signupWithEmail and return session', async () => { + it('should call service.signupWithEmail and return session DTO', async () => { const params: SignupParams = { email: 'test@example.com', password: 'password123', @@ -36,7 +36,7 @@ describe('AuthController', () => { displayName: 'Test User', }, }; - service.signupWithEmail.mockResolvedValue(session); + (service.signupWithEmail as jest.Mock).mockResolvedValue(session); const result = await controller.signup(params); @@ -46,7 +46,7 @@ describe('AuthController', () => { }); describe('login', () => { - it('should call service.loginWithEmail and return session', async () => { + it('should call service.loginWithEmail and return session DTO', async () => { const params: LoginParams = { email: 'test@example.com', password: 'password123', @@ -59,7 +59,7 @@ describe('AuthController', () => { displayName: 'Test User', }, }; - service.loginWithEmail.mockResolvedValue(session); + (service.loginWithEmail as jest.Mock).mockResolvedValue(session); const result = await controller.login(params); @@ -69,7 +69,7 @@ describe('AuthController', () => { }); describe('getSession', () => { - it('should call service.getCurrentSession and return session', async () => { + it('should call service.getCurrentSession and return session DTO', async () => { const session: AuthSessionDTO = { token: 'token123', user: { @@ -78,7 +78,7 @@ describe('AuthController', () => { displayName: 'Test User', }, }; - service.getCurrentSession.mockResolvedValue(session); + (service.getCurrentSession as jest.Mock).mockResolvedValue(session); const result = await controller.getSession(); @@ -87,7 +87,7 @@ describe('AuthController', () => { }); it('should return null if no session', async () => { - service.getCurrentSession.mockResolvedValue(null); + (service.getCurrentSession as jest.Mock).mockResolvedValue(null); const result = await controller.getSession(); @@ -96,13 +96,14 @@ describe('AuthController', () => { }); describe('logout', () => { - it('should call service.logout', async () => { - service.logout.mockResolvedValue(undefined); + it('should call service.logout and return DTO', async () => { + const dto = { success: true }; + (service.logout as jest.Mock).mockResolvedValue(dto); - await controller.logout(); + const result = await controller.logout(); expect(service.logout).toHaveBeenCalled(); + expect(result).toEqual(dto); }); }); - }); \ No newline at end of file diff --git a/apps/api/src/domain/auth/AuthController.ts b/apps/api/src/domain/auth/AuthController.ts index 25d7d1d66..e16accb84 100644 --- a/apps/api/src/domain/auth/AuthController.ts +++ b/apps/api/src/domain/auth/AuthController.ts @@ -8,26 +8,21 @@ export class AuthController { @Post('signup') async signup(@Body() params: SignupParams): Promise { - const presenter = await this.authService.signupWithEmail(params); - return presenter.viewModel; + return this.authService.signupWithEmail(params); } @Post('login') async login(@Body() params: LoginParams): Promise { - const presenter = await this.authService.loginWithEmail(params); - return presenter.viewModel; + return this.authService.loginWithEmail(params); } @Get('session') async getSession(): Promise { - const presenter = await this.authService.getCurrentSession(); - return presenter ? presenter.viewModel : null; + return this.authService.getCurrentSession(); } @Post('logout') async logout(): Promise<{ success: boolean }> { - const presenter = await this.authService.logout(); - return presenter.viewModel; + return this.authService.logout(); } - } diff --git a/apps/api/src/domain/auth/AuthProviders.ts b/apps/api/src/domain/auth/AuthProviders.ts index 9f0623777..be435f779 100644 --- a/apps/api/src/domain/auth/AuthProviders.ts +++ b/apps/api/src/domain/auth/AuthProviders.ts @@ -10,6 +10,17 @@ import { InMemoryUserRepository } from '@adapters/identity/persistence/inmemory/ import { InMemoryPasswordHashingService } from '@adapters/identity/services/InMemoryPasswordHashingService'; import { ConsoleLogger } from '@adapters/logging/ConsoleLogger'; import { CookieIdentitySessionAdapter } from '@adapters/identity/session/CookieIdentitySessionAdapter'; +import { LoginUseCase } from '@core/identity/application/use-cases/LoginUseCase'; +import { LogoutUseCase } from '@core/identity/application/use-cases/LogoutUseCase'; +import { SignupUseCase } from '@core/identity/application/use-cases/SignupUseCase'; +import { AuthSessionPresenter } from './presenters/AuthSessionPresenter'; +import { CommandResultPresenter } from './presenters/CommandResultPresenter'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { LoginResult } from '@core/identity/application/use-cases/LoginUseCase'; +import type { SignupResult } from '@core/identity/application/use-cases/SignupUseCase'; +import type { LogoutResult } from '@core/identity/application/use-cases/LogoutUseCase'; +import type { IAuthRepository } from '@core/identity/domain/repositories/IAuthRepository'; +import type { IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort'; // Define the tokens for dependency injection export const AUTH_REPOSITORY_TOKEN = 'IAuthRepository'; @@ -17,6 +28,9 @@ export const USER_REPOSITORY_TOKEN = 'IUserRepository'; export const PASSWORD_HASHING_SERVICE_TOKEN = 'IPasswordHashingService'; export const LOGGER_TOKEN = 'Logger'; export const IDENTITY_SESSION_PORT_TOKEN = 'IdentitySessionPort'; +export const LOGIN_USE_CASE_TOKEN = 'LoginUseCase'; +export const SIGNUP_USE_CASE_TOKEN = 'SignupUseCase'; +export const LOGOUT_USE_CASE_TOKEN = 'LogoutUseCase'; export const AuthProviders: Provider[] = [ { @@ -57,4 +71,22 @@ export const AuthProviders: Provider[] = [ useFactory: (logger: Logger) => new CookieIdentitySessionAdapter(logger), inject: [LOGGER_TOKEN], }, + { + provide: LOGIN_USE_CASE_TOKEN, + useFactory: (authRepo: IAuthRepository, passwordHashing: IPasswordHashingService, logger: Logger) => + new LoginUseCase(authRepo, passwordHashing, logger), + inject: [AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN], + }, + { + provide: SIGNUP_USE_CASE_TOKEN, + useFactory: (authRepo: IAuthRepository, passwordHashing: IPasswordHashingService, logger: Logger) => + new SignupUseCase(authRepo, passwordHashing, logger), + inject: [AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN], + }, + { + provide: LOGOUT_USE_CASE_TOKEN, + useFactory: (sessionPort: IdentitySessionPort, logger: Logger) => + new LogoutUseCase(sessionPort, logger), + inject: [IDENTITY_SESSION_PORT_TOKEN, LOGGER_TOKEN], + }, ]; diff --git a/apps/api/src/domain/auth/AuthService.ts b/apps/api/src/domain/auth/AuthService.ts index 106f9975b..0c9450929 100644 --- a/apps/api/src/domain/auth/AuthService.ts +++ b/apps/api/src/domain/auth/AuthService.ts @@ -1,40 +1,33 @@ -import { Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; // Core Use Cases -import { LoginUseCase } from '@core/identity/application/use-cases/LoginUseCase'; +import { LoginUseCase, type LoginInput } from '@core/identity/application/use-cases/LoginUseCase'; import { LogoutUseCase } from '@core/identity/application/use-cases/LogoutUseCase'; -import { SignupUseCase } from '@core/identity/application/use-cases/SignupUseCase'; +import { SignupUseCase, type SignupInput } from '@core/identity/application/use-cases/SignupUseCase'; // Core Interfaces and Tokens -import { AuthenticatedUserDTO as CoreAuthenticatedUserDTO } from '@core/identity/application/dto/AuthenticatedUserDTO'; import { IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort'; import { User } from '@core/identity/domain/entities/User'; -import type { IAuthRepository } from '@core/identity/domain/repositories/IAuthRepository'; import type { IUserRepository } from '@core/identity/domain/repositories/IUserRepository'; -import type { IPasswordHashingService } from '@core/identity/domain/services/PasswordHashingService'; -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 type { Logger } from '@core/shared/application'; +import { IDENTITY_SESSION_PORT_TOKEN, LOGGER_TOKEN, LOGIN_USE_CASE_TOKEN, LOGOUT_USE_CASE_TOKEN, SIGNUP_USE_CASE_TOKEN, USER_REPOSITORY_TOKEN } from './AuthProviders'; +import { AuthenticatedUserDTO, AuthSessionDTO, LoginParams, SignupParams } from './dtos/AuthDto'; import { AuthSessionPresenter } from './presenters/AuthSessionPresenter'; +import type { CommandResultDTO } from './presenters/CommandResultPresenter'; import { CommandResultPresenter } from './presenters/CommandResultPresenter'; @Injectable() export class AuthService { - private readonly loginUseCase: LoginUseCase; - private readonly signupUseCase: SignupUseCase; - private readonly logoutUseCase: LogoutUseCase; - constructor( - @Inject(AUTH_REPOSITORY_TOKEN) private authRepository: IAuthRepository, - @Inject(PASSWORD_HASHING_SERVICE_TOKEN) private passwordHashingService: IPasswordHashingService, @Inject(LOGGER_TOKEN) private logger: Logger, @Inject(IDENTITY_SESSION_PORT_TOKEN) private identitySessionPort: IdentitySessionPort, - @Inject(USER_REPOSITORY_TOKEN) private userRepository: IUserRepository, // Inject IUserRepository here - ) { - this.loginUseCase = new LoginUseCase(this.authRepository, this.passwordHashingService); - this.signupUseCase = new SignupUseCase(this.authRepository, this.passwordHashingService); - this.logoutUseCase = new LogoutUseCase(this.identitySessionPort); - } + @Inject(USER_REPOSITORY_TOKEN) private userRepository: IUserRepository, + @Inject(LOGIN_USE_CASE_TOKEN) private readonly loginUseCase: LoginUseCase, + @Inject(SIGNUP_USE_CASE_TOKEN) private readonly signupUseCase: SignupUseCase, + @Inject(LOGOUT_USE_CASE_TOKEN) private readonly logoutUseCase: LogoutUseCase, + private readonly authSessionPresenter: AuthSessionPresenter, + private readonly commandResultPresenter: CommandResultPresenter, + ) {} private mapUserToAuthenticatedUserDTO(user: User): AuthenticatedUserDTO { return { @@ -44,74 +37,109 @@ export class AuthService { }; } - private mapToCoreAuthenticatedUserDTO(apiDto: AuthenticatedUserDTO): CoreAuthenticatedUserDTO { + + private buildAuthSessionDTO(token: string, user: AuthenticatedUserDTO): AuthSessionDTO { return { - id: apiDto.userId, - displayName: apiDto.displayName, - email: apiDto.email, + token, + user: { + userId: user.userId, + email: user.email, + displayName: user.displayName, + }, }; } - async getCurrentSession(): Promise { + async getCurrentSession(): Promise { this.logger.debug('[AuthService] Attempting to get current session.'); const coreSession = await this.identitySessionPort.getCurrentSession(); if (!coreSession) { return null; } - const user = await this.userRepository.findById(coreSession.user.id); // Use userRepository to fetch full user + const user = await this.userRepository.findById(coreSession.user.id); 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 + this.logger.warn( + `[AuthService] Session found for user ID ${coreSession.user.id}, but user not found in repository.`, + ); + await this.identitySessionPort.clearSession(); return null; } const authenticatedUserDTO = this.mapUserToAuthenticatedUserDTO(User.fromStored(user)); + const apiSession = this.buildAuthSessionDTO(coreSession.token, authenticatedUserDTO); - const presenter = new AuthSessionPresenter(); - presenter.present({ token: coreSession.token, user: authenticatedUserDTO }); - return presenter; + return apiSession; } - 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); + const input: SignupInput = { + email: params.email, + password: params.password, + displayName: params.displayName, + }; - const presenter = new AuthSessionPresenter(); - presenter.present({ token: session.token, user: authenticatedUserDTO }); - return presenter; - } + const result = await this.signupUseCase.execute(input); - 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); - // Create session after successful login - const authenticatedUserDTO = this.mapUserToAuthenticatedUserDTO(user); - const coreDto = this.mapToCoreAuthenticatedUserDTO(authenticatedUserDTO); - const session = await this.identitySessionPort.createSession(coreDto); - - 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.'); + if (result.isErr()) { + const error = result.unwrapErr(); + throw new Error(error.details?.message ?? 'Signup failed'); } + + const userDTO = this.authSessionPresenter.getResponseModel(); + const coreUserDTO = { + id: userDTO.userId, + displayName: userDTO.displayName, + email: userDTO.email, + }; + const session = await this.identitySessionPort.createSession(coreUserDTO); + + return { + token: session.token, + user: userDTO, + }; } + async loginWithEmail(params: LoginParams): Promise { + this.logger.debug(`[AuthService] Attempting login for email: ${params.email}`); - async logout(): Promise { + const input: LoginInput = { + email: params.email, + password: params.password, + }; + + const result = await this.loginUseCase.execute(input); + + if (result.isErr()) { + const error = result.unwrapErr(); + throw new Error(error.details?.message ?? 'Login failed'); + } + + const userDTO = this.authSessionPresenter.getResponseModel(); + const coreUserDTO = { + id: userDTO.userId, + displayName: userDTO.displayName, + email: userDTO.email, + }; + const session = await this.identitySessionPort.createSession(coreUserDTO); + + return { + token: session.token, + user: userDTO, + }; + } + + async logout(): Promise { this.logger.debug('[AuthService] Attempting logout.'); - const presenter = new CommandResultPresenter(); - await this.logoutUseCase.execute(); - presenter.present({ success: true }); - return presenter; + + const result = await this.logoutUseCase.execute(); + + if (result.isErr()) { + const error = result.unwrapErr(); + throw new Error(error.details?.message ?? 'Logout failed'); + } + + return this.commandResultPresenter.getResponseModel(); } } diff --git a/apps/api/src/domain/auth/presenters/AuthSessionPresenter.test.ts b/apps/api/src/domain/auth/presenters/AuthSessionPresenter.test.ts index e8ebe6546..9f7e9eb8d 100644 --- a/apps/api/src/domain/auth/presenters/AuthSessionPresenter.test.ts +++ b/apps/api/src/domain/auth/presenters/AuthSessionPresenter.test.ts @@ -1,61 +1,44 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { AuthSessionPresenter } from './AuthSessionPresenter'; -import { AuthenticatedUserDTO } from '../dtos/AuthDto'; +import { User } from '@core/identity/domain/entities/User'; +import { UserId } from '@core/identity/domain/value-objects/UserId'; describe('AuthSessionPresenter', () => { let presenter: AuthSessionPresenter; + let mockIdentitySessionPort: any; beforeEach(() => { - presenter = new AuthSessionPresenter(); + mockIdentitySessionPort = { + createSession: vi.fn(), + }; + presenter = new AuthSessionPresenter(mockIdentitySessionPort); }); - it('maps token and user DTO correctly', () => { - const user: AuthenticatedUserDTO = { - userId: 'user-1', - email: 'user@example.com', + it('maps successful result into response model', async () => { + const user = User.create({ + id: UserId.fromString('user-1'), displayName: 'Test User', - }; + email: 'user@example.com', + passwordHash: { value: 'hash' } as any, + }); - presenter.present({ token: 'token-123', user }); - - expect(presenter.viewModel).toEqual({ + const expectedSession = { 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(); + mockIdentitySessionPort.createSession.mockResolvedValue(expectedSession); - presenter.reset(); + await presenter.present({ user }); - expect(() => presenter.viewModel).toThrow('Presenter not presented'); + expect(presenter.getResponseModel()).toEqual(expectedSession); }); - 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); + it('getResponseModel throws when not presented', () => { + expect(() => presenter.getResponseModel()).toThrow('Response model not set'); }); }); diff --git a/apps/api/src/domain/auth/presenters/AuthSessionPresenter.ts b/apps/api/src/domain/auth/presenters/AuthSessionPresenter.ts index a16b3eef5..0e0e81ad1 100644 --- a/apps/api/src/domain/auth/presenters/AuthSessionPresenter.ts +++ b/apps/api/src/domain/auth/presenters/AuthSessionPresenter.ts @@ -1,31 +1,24 @@ -import { AuthSessionDTO, AuthenticatedUserDTO } from '../dtos/AuthDto'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import { AuthenticatedUserDTO } from '../dtos/AuthDto'; +import type { User } from '@core/identity/domain/entities/User'; -export interface AuthSessionViewModel extends AuthSessionDTO {} +export class AuthSessionPresenter implements UseCaseOutputPort<{ user: User }> { + private responseModel: AuthenticatedUserDTO | null = null; -export class AuthSessionPresenter { - private result: AuthSessionViewModel | null = null; + present(result: { user: User }): void { + const { user } = result; - 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, - }, + this.responseModel = { + userId: user.getId().value, + email: user.getEmail() ?? '', + displayName: user.getDisplayName() ?? '', }; } - get viewModel(): AuthSessionViewModel { - if (!this.result) throw new Error('Presenter not presented'); - return this.result; - } - - getViewModel(): AuthSessionViewModel | null { - return this.result; + getResponseModel(): AuthenticatedUserDTO { + if (!this.responseModel) { + throw new Error('Response model not set'); + } + return this.responseModel; } } diff --git a/apps/api/src/domain/auth/presenters/CommandResultPresenter.ts b/apps/api/src/domain/auth/presenters/CommandResultPresenter.ts new file mode 100644 index 000000000..5e119c5b0 --- /dev/null +++ b/apps/api/src/domain/auth/presenters/CommandResultPresenter.ts @@ -0,0 +1,23 @@ +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; + +export interface CommandResultDTO { + success: boolean; + message?: string; +} + +export class CommandResultPresenter implements UseCaseOutputPort<{ success: boolean }> { + private responseModel: CommandResultDTO | null = null; + + present(result: { success: boolean }): void { + this.responseModel = { + success: result.success, + }; + } + + getResponseModel(): CommandResultDTO { + if (!this.responseModel) { + throw new Error('Response model not set'); + } + return this.responseModel; + } +} diff --git a/apps/api/src/domain/dashboard/DashboardController.test.ts b/apps/api/src/domain/dashboard/DashboardController.test.ts index 3a3d71c02..0b278304a 100644 --- a/apps/api/src/domain/dashboard/DashboardController.test.ts +++ b/apps/api/src/domain/dashboard/DashboardController.test.ts @@ -1,7 +1,5 @@ -import { Test, TestingModule } from '@nestjs/testing'; import { vi } from 'vitest'; import { DashboardController } from './DashboardController'; -import { DashboardService } from './DashboardService'; import { DashboardOverviewDTO } from './dtos/DashboardOverviewDTO'; describe('DashboardController', () => { @@ -13,7 +11,7 @@ describe('DashboardController', () => { getDashboardOverview: vi.fn(), }; - controller = new DashboardController(mockService as any); + controller = new DashboardController(mockService as never); }); describe('getDashboardOverview', () => { diff --git a/apps/api/src/domain/dashboard/DashboardController.ts b/apps/api/src/domain/dashboard/DashboardController.ts index 2b963cd7a..cbc7a2bfc 100644 --- a/apps/api/src/domain/dashboard/DashboardController.ts +++ b/apps/api/src/domain/dashboard/DashboardController.ts @@ -13,7 +13,6 @@ export class DashboardController { @ApiQuery({ name: 'driverId', description: 'Driver ID' }) @ApiResponse({ status: 200, description: 'Dashboard overview', type: DashboardOverviewDTO }) async getDashboardOverview(@Query('driverId') driverId: string): Promise { - const presenter = await this.dashboardService.getDashboardOverview(driverId); - return presenter.viewModel; + return this.dashboardService.getDashboardOverview(driverId); } } \ No newline at end of file diff --git a/apps/api/src/domain/dashboard/DashboardProviders.ts b/apps/api/src/domain/dashboard/DashboardProviders.ts index 8f254d16e..2ad26d47a 100644 --- a/apps/api/src/domain/dashboard/DashboardProviders.ts +++ b/apps/api/src/domain/dashboard/DashboardProviders.ts @@ -1,8 +1,9 @@ import { Provider } from '@nestjs/common'; -import { DashboardService } from './DashboardService'; // Import core interfaces import type { Logger } from '@core/shared/application/Logger'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { DashboardOverviewResult } from '@core/racing/application/use-cases/DashboardOverviewUseCase'; import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository'; import { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository'; import { IResultRepository } from '@core/racing/domain/repositories/IResultRepository'; @@ -12,7 +13,8 @@ import { ILeagueMembershipRepository } from '@core/racing/domain/repositories/IL import { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository'; import { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository'; import { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository'; -import { IImageServicePort } from '@core/racing/application/ports/IImageServicePort'; +import { ImageServicePort } from '@core/media/application/ports/ImageServicePort'; +import { DashboardOverviewUseCase } from '@core/racing/application/use-cases/DashboardOverviewUseCase'; // Import concrete implementations import { ConsoleLogger } from '@adapters/logging/ConsoleLogger'; @@ -24,25 +26,31 @@ import { InMemoryStandingRepository } from '@adapters/racing/persistence/inmemor import { InMemoryLeagueMembershipRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueMembershipRepository'; import { InMemoryRaceRegistrationRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRegistrationRepository'; import { InMemoryImageServiceAdapter } from '@adapters/media/ports/InMemoryImageServiceAdapter'; +import { DashboardOverviewPresenter } from './presenters/DashboardOverviewPresenter'; // Simple mock implementations for missing adapters class MockFeedRepository implements IFeedRepository { - async getFeedForDriver(driverId: string, limit?: number) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getFeedForDriver(_driverId: string, _limit?: number) { return []; } - async getGlobalFeed(limit?: number) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getGlobalFeed(_limit?: number) { return []; } } class MockSocialGraphRepository implements ISocialGraphRepository { - async getFriends(driverId: string) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getFriends(_driverId: string) { return []; } - async getFriendIds(driverId: string) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getFriendIds(_driverId: string) { return []; } - async getSuggestedFriends(driverId: string, limit?: number) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getSuggestedFriends(_driverId: string, _limit?: number) { return []; } } @@ -59,8 +67,11 @@ export const RACE_REGISTRATION_REPOSITORY_TOKEN = 'IRaceRegistrationRepository'; export const FEED_REPOSITORY_TOKEN = 'IFeedRepository'; export const SOCIAL_GRAPH_REPOSITORY_TOKEN = 'ISocialGraphRepository'; export const IMAGE_SERVICE_TOKEN = 'IImageServicePort'; +export const DASHBOARD_OVERVIEW_USE_CASE_TOKEN = 'DashboardOverviewUseCase'; +export const DASHBOARD_OVERVIEW_OUTPUT_PORT_TOKEN = 'DashboardOverviewOutputPort'; export const DashboardProviders: Provider[] = [ + DashboardOverviewPresenter, { provide: LOGGER_TOKEN, useClass: ConsoleLogger, @@ -113,4 +124,51 @@ export const DashboardProviders: Provider[] = [ useFactory: (logger: Logger) => new InMemoryImageServiceAdapter(logger), inject: [LOGGER_TOKEN], }, + { + provide: DASHBOARD_OVERVIEW_OUTPUT_PORT_TOKEN, + useExisting: DashboardOverviewPresenter, + }, + { + provide: DASHBOARD_OVERVIEW_USE_CASE_TOKEN, + useFactory: ( + driverRepo: IDriverRepository, + raceRepo: IRaceRepository, + resultRepo: IResultRepository, + leagueRepo: ILeagueRepository, + standingRepo: IStandingRepository, + membershipRepo: ILeagueMembershipRepository, + registrationRepo: IRaceRegistrationRepository, + feedRepo: IFeedRepository, + socialRepo: ISocialGraphRepository, + imageService: ImageServicePort, + output: UseCaseOutputPort, + ) => + new DashboardOverviewUseCase( + driverRepo, + raceRepo, + resultRepo, + leagueRepo, + standingRepo, + membershipRepo, + registrationRepo, + feedRepo, + socialRepo, + async (driverId: string) => imageService.getDriverAvatar(driverId), + () => null, + output, + ), + inject: [ + DRIVER_REPOSITORY_TOKEN, + RACE_REPOSITORY_TOKEN, + RESULT_REPOSITORY_TOKEN, + LEAGUE_REPOSITORY_TOKEN, + STANDING_REPOSITORY_TOKEN, + LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, + RACE_REGISTRATION_REPOSITORY_TOKEN, + FEED_REPOSITORY_TOKEN, + SOCIAL_GRAPH_REPOSITORY_TOKEN, + IMAGE_SERVICE_TOKEN, + DASHBOARD_OVERVIEW_OUTPUT_PORT_TOKEN, + ], + }, ]; \ 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 f3ffd0ba7..9fe8f07b7 100644 --- a/apps/api/src/domain/dashboard/DashboardService.ts +++ b/apps/api/src/domain/dashboard/DashboardService.ts @@ -1,80 +1,31 @@ import { Injectable, Inject } from '@nestjs/common'; 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'; -import type { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository'; -import type { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository'; -import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository'; -import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository'; -import type { IStandingRepository } from '@core/racing/domain/repositories/IStandingRepository'; -import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository'; -import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository'; -import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository'; -import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository'; -import type { IImageServicePort } from '@core/racing/application/ports/IImageServicePort'; // Tokens -import { - LOGGER_TOKEN, - DRIVER_REPOSITORY_TOKEN, - RACE_REPOSITORY_TOKEN, - RESULT_REPOSITORY_TOKEN, - LEAGUE_REPOSITORY_TOKEN, - STANDING_REPOSITORY_TOKEN, - LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, - RACE_REGISTRATION_REPOSITORY_TOKEN, - FEED_REPOSITORY_TOKEN, - SOCIAL_GRAPH_REPOSITORY_TOKEN, - IMAGE_SERVICE_TOKEN, -} from './DashboardProviders'; +import { LOGGER_TOKEN, DASHBOARD_OVERVIEW_USE_CASE_TOKEN } from './DashboardProviders'; @Injectable() export class DashboardService { - private readonly dashboardOverviewUseCase: DashboardOverviewUseCase; - constructor( @Inject(LOGGER_TOKEN) private readonly logger: Logger, - @Inject(DRIVER_REPOSITORY_TOKEN) private readonly driverRepository?: IDriverRepository, - @Inject(RACE_REPOSITORY_TOKEN) private readonly raceRepository?: IRaceRepository, - @Inject(RESULT_REPOSITORY_TOKEN) private readonly resultRepository?: IResultRepository, - @Inject(LEAGUE_REPOSITORY_TOKEN) private readonly leagueRepository?: ILeagueRepository, - @Inject(STANDING_REPOSITORY_TOKEN) private readonly standingRepository?: IStandingRepository, - @Inject(LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN) private readonly leagueMembershipRepository?: ILeagueMembershipRepository, - @Inject(RACE_REGISTRATION_REPOSITORY_TOKEN) private readonly raceRegistrationRepository?: IRaceRegistrationRepository, - @Inject(FEED_REPOSITORY_TOKEN) private readonly feedRepository?: IFeedRepository, - @Inject(SOCIAL_GRAPH_REPOSITORY_TOKEN) private readonly socialRepository?: ISocialGraphRepository, - @Inject(IMAGE_SERVICE_TOKEN) private readonly imageService?: IImageServicePort, - ) { - this.dashboardOverviewUseCase = new DashboardOverviewUseCase( - driverRepository, - raceRepository, - resultRepository, - leagueRepository, - standingRepository, - leagueMembershipRepository, - raceRegistrationRepository, - feedRepository, - socialRepository, - imageService, - () => null, // getDriverStats - ); - } + @Inject(DASHBOARD_OVERVIEW_USE_CASE_TOKEN) private readonly dashboardOverviewUseCase: DashboardOverviewUseCase, + private readonly dashboardOverviewPresenter: DashboardOverviewPresenter, + ) {} - 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 }); if (result.isErr()) { - throw new Error(result.error?.message || 'Failed to get dashboard overview'); + throw new Error(result.unwrapErr().details?.message ?? 'Failed to get dashboard overview'); } - const presenter = new DashboardOverviewPresenter(); - presenter.present(result.value as DashboardOverviewOutputPort); - return presenter; + return this.dashboardOverviewPresenter.getResponseModel(); } } \ No newline at end of file diff --git a/apps/api/src/domain/dashboard/dtos/DashboardOverviewDTO.ts b/apps/api/src/domain/dashboard/dtos/DashboardOverviewDTO.ts index f20995bb9..886c7e465 100644 --- a/apps/api/src/domain/dashboard/dtos/DashboardOverviewDTO.ts +++ b/apps/api/src/domain/dashboard/dtos/DashboardOverviewDTO.ts @@ -15,9 +15,10 @@ export class DashboardDriverSummaryDTO { @IsString() country!: string; - @ApiProperty() + @ApiProperty({ nullable: true }) + @IsOptional() @IsString() - avatarUrl!: string; + avatarUrl?: string | null; @ApiProperty({ nullable: true }) @IsOptional() @@ -52,13 +53,15 @@ export class DashboardRaceSummaryDTO { @IsString() id!: string; - @ApiProperty() + @ApiProperty({ nullable: true }) + @IsOptional() @IsString() - leagueId!: string; + leagueId?: string | null; - @ApiProperty() + @ApiProperty({ nullable: true }) + @IsOptional() @IsString() - leagueName!: string; + leagueName?: string | null; @ApiProperty() @IsString() @@ -90,13 +93,15 @@ export class DashboardRecentResultDTO { @IsString() raceName!: string; - @ApiProperty() + @ApiProperty({ nullable: true }) + @IsOptional() @IsString() - leagueId!: string; + leagueId?: string | null; - @ApiProperty() + @ApiProperty({ nullable: true }) + @IsOptional() @IsString() - leagueName!: string; + leagueName?: string | null; @ApiProperty() @IsString() @@ -120,17 +125,19 @@ export class DashboardLeagueStandingSummaryDTO { @IsString() leagueName!: string; - @ApiProperty() + @ApiProperty({ nullable: true }) + @IsOptional() @IsNumber() - position!: number; + position?: number | null; @ApiProperty() @IsNumber() totalDrivers!: number; - @ApiProperty() + @ApiProperty({ nullable: true }) + @IsOptional() @IsNumber() - points!: number; + points?: number | null; } export class DashboardFeedItemSummaryDTO { @@ -191,9 +198,10 @@ export class DashboardFriendSummaryDTO { @IsString() country!: string; - @ApiProperty() + @ApiProperty({ nullable: true }) + @IsOptional() @IsString() - avatarUrl!: string; + avatarUrl?: string | null; } export class DashboardOverviewDTO { diff --git a/apps/api/src/domain/dashboard/presenters/DashboardOverviewPresenter.test.ts b/apps/api/src/domain/dashboard/presenters/DashboardOverviewPresenter.test.ts index 9aee1b6c3..4315d63fa 100644 --- a/apps/api/src/domain/dashboard/presenters/DashboardOverviewPresenter.test.ts +++ b/apps/api/src/domain/dashboard/presenters/DashboardOverviewPresenter.test.ts @@ -1,120 +1,137 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { DashboardOverviewPresenter } from './DashboardOverviewPresenter'; -import type { DashboardOverviewOutputPort } from '@core/racing/application/ports/output/DashboardOverviewOutputPort'; +import type { DashboardOverviewResult } from '@core/racing/application/use-cases/DashboardOverviewUseCase'; +import { Driver } from '@core/racing/domain/entities/Driver'; +import { Race } from '@core/racing/domain/entities/Race'; +import { League } from '@core/racing/domain/entities/League'; +import { Standing } from '@core/racing/domain/entities/Standing'; +import { Result as RaceResult } from '@core/racing/domain/entities/result/Result'; +import type { FeedItem } from '@core/social/domain/types/FeedItem'; -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: { +const createOutput = (): DashboardOverviewResult => { + const driver = Driver.create({ id: 'driver-1', iracingId: '12345', name: 'Test Driver', country: 'DE' }); + const league1 = League.create({ id: 'league-1', name: 'League 1', description: 'First league', ownerId: 'owner-1' }); + const league2 = League.create({ id: 'league-2', name: 'League 2', description: 'Second league', ownerId: 'owner-2' }); + const league3 = League.create({ id: 'league-3', name: 'League 3', description: 'Third league', ownerId: 'owner-3' }); + + const race1 = Race.create({ id: 'race-1', leagueId: 'league-1', - leagueName: 'League 1', track: 'Spa', car: 'GT3', - scheduledAt: '2025-01-01T10:00:00Z', + scheduledAt: new Date('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, + }); + const race2 = Race.create({ + id: 'race-2', + leagueId: 'league-2', + track: 'Monza', + car: 'GT3', + scheduledAt: new Date('2025-01-02T10:00:00Z'), + status: 'scheduled', + }); + const race3 = Race.create({ + id: 'race-3', + leagueId: 'league-3', + track: 'Nürburgring', + car: 'GT3', + scheduledAt: new Date('2024-12-01T10:00:00Z'), + status: 'completed', + }); + + const standing1 = Standing.create({ leagueId: 'league-1', driverId: 'driver-1', position: 1, points: 150 }); + + const result1 = RaceResult.create({ + id: 'result-1', + raceId: 'race-3', + driverId: 'driver-1', + position: 1, + fastestLap: 120, + incidents: 0, + startPosition: 1, + }); + + const feedItem: FeedItem = { + id: 'feed-1', + type: 'friend-joined-league', + headline: 'You won a race', + body: 'Congrats!', + timestamp: new Date('2024-12-02T10:00:00Z'), + ctaLabel: 'View', + ctaHref: '/races/race-3', + }; + + const friend = Driver.create({ id: 'friend-1', iracingId: '67890', name: 'Friend One', country: 'US' }); + + return { + currentDriver: { + driver, + avatarUrl: 'https://example.com/avatar.jpg', + rating: 2500, + globalRank: 42, + totalRaces: 10, + wins: 3, + podiums: 5, + consistency: 90, }, - ], - leagueStandingsSummaries: [ - { - leagueId: 'league-1', - leagueName: 'League 1', - position: 1, - totalDrivers: 20, - points: 150, - }, - ], - feedSummary: { - notificationCount: 3, - items: [ + myUpcomingRaces: [ { - 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', + race: race1, + league: league1, + isMyLeague: true, }, ], - }, - friends: [ - { - id: 'friend-1', - name: 'Friend One', - country: 'US', - avatarUrl: 'https://example.com/friend.jpg', + otherUpcomingRaces: [ + { + race: race2, + league: league2, + isMyLeague: false, + }, + ], + upcomingRaces: [ + { + race: race1, + league: league1, + isMyLeague: true, + }, + { + race: race2, + league: league2, + isMyLeague: false, + }, + ], + activeLeaguesCount: 2, + nextRace: { + race: race1, + league: league1, + isMyLeague: true, }, - ], -}); + recentResults: [ + { + race: race3, + league: league3, + result: result1, + }, + ], + leagueStandingsSummaries: [ + { + league: league1, + standing: standing1, + totalDrivers: 20, + }, + ], + feedSummary: { + notificationCount: 3, + items: [feedItem], + }, + friends: [ + { + driver: friend, + avatarUrl: 'https://example.com/friend.jpg', + }, + ], + }; +}; describe('DashboardOverviewPresenter', () => { let presenter: DashboardOverviewPresenter; @@ -123,44 +140,23 @@ describe('DashboardOverviewPresenter', () => { presenter = new DashboardOverviewPresenter(); }); - it('maps DashboardOverviewOutputPort to DashboardOverviewDTO correctly', () => { + it('maps DashboardOverviewResult to DashboardOverviewDTO correctly', () => { const output = createOutput(); presenter.present(output); + const dto = presenter.getResponseModel(); - 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'); + expect(dto.activeLeaguesCount).toBe(2); + expect(dto.currentDriver?.id).toBe('driver-1'); + expect(dto.myUpcomingRaces[0].id).toBe('race-1'); + expect(dto.otherUpcomingRaces[0].id).toBe('race-2'); + expect(dto.upcomingRaces).toHaveLength(2); + expect(dto.nextRace?.id).toBe('race-1'); + expect(dto.recentResults[0].raceId).toBe('race-3'); + expect(dto.leagueStandingsSummaries[0].leagueId).toBe('league-1'); + expect(dto.feedSummary.notificationCount).toBe(3); + expect(dto.feedSummary.items[0].id).toBe('feed-1'); + expect(dto.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 index 756510959..5fe0a45f9 100644 --- a/apps/api/src/domain/dashboard/presenters/DashboardOverviewPresenter.ts +++ b/apps/api/src/domain/dashboard/presenters/DashboardOverviewPresenter.ts @@ -1,4 +1,7 @@ -import type { DashboardOverviewOutputPort } from '@core/racing/application/ports/output/DashboardOverviewOutputPort'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { + DashboardOverviewResult, +} from '@core/racing/application/use-cases/DashboardOverviewUseCase'; import { DashboardOverviewDTO, DashboardDriverSummaryDTO, @@ -10,93 +13,89 @@ import { DashboardFriendSummaryDTO, } from '../dtos/DashboardOverviewDTO'; -export class DashboardOverviewPresenter { - private result: DashboardOverviewDTO | null = null; +export class DashboardOverviewPresenter implements UseCaseOutputPort { + private responseModel: DashboardOverviewDTO | null = null; - reset() { - this.result = null; - } - - present(output: DashboardOverviewOutputPort): void { - const currentDriver: DashboardDriverSummaryDTO | null = output.currentDriver + present(result: DashboardOverviewResult): void { + const currentDriver: DashboardDriverSummaryDTO | null = result.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, + id: result.currentDriver.driver.id, + name: String(result.currentDriver.driver.name), + country: String(result.currentDriver.driver.country), + avatarUrl: result.currentDriver.avatarUrl, + rating: result.currentDriver.rating, + globalRank: result.currentDriver.globalRank, + totalRaces: result.currentDriver.totalRaces, + wins: result.currentDriver.wins, + podiums: result.currentDriver.podiums, + consistency: result.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 mapRace = (raceSummary: DashboardOverviewResult['myUpcomingRaces'][number]): DashboardRaceSummaryDTO => ({ + id: raceSummary.race.id, + leagueId: raceSummary.league?.id ? String(raceSummary.league.id) : null, + leagueName: raceSummary.league?.name ? String(raceSummary.league.name) : null, + track: String(raceSummary.race.track), + car: String(raceSummary.race.car), + scheduledAt: raceSummary.race.scheduledAt.toISOString(), + status: raceSummary.race.status as 'scheduled' | 'running' | 'completed' | 'cancelled', + isMyLeague: raceSummary.isMyLeague, }); - const myUpcomingRaces: DashboardRaceSummaryDTO[] = output.myUpcomingRaces.map(mapRace); - const otherUpcomingRaces: DashboardRaceSummaryDTO[] = output.otherUpcomingRaces.map(mapRace); - const upcomingRaces: DashboardRaceSummaryDTO[] = output.upcomingRaces.map(mapRace); + const myUpcomingRaces: DashboardRaceSummaryDTO[] = result.myUpcomingRaces.map(mapRace); + const otherUpcomingRaces: DashboardRaceSummaryDTO[] = result.otherUpcomingRaces.map(mapRace); + const upcomingRaces: DashboardRaceSummaryDTO[] = result.upcomingRaces.map(mapRace); - const nextRace: DashboardRaceSummaryDTO | null = output.nextRace ? mapRace(output.nextRace) : null; + const nextRace: DashboardRaceSummaryDTO | null = result.nextRace ? mapRace(result.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 recentResults: DashboardRecentResultDTO[] = result.recentResults.map(resultSummary => ({ + raceId: resultSummary.race.id, + raceName: String(resultSummary.race.track), + leagueId: resultSummary.league?.id ? String(resultSummary.league.id) : null, + leagueName: resultSummary.league?.name ? String(resultSummary.league.name) : null, + finishedAt: resultSummary.race.scheduledAt.toISOString(), + position: Number(resultSummary.result.position), + incidents: Number(resultSummary.result.incidents), })); const leagueStandingsSummaries: DashboardLeagueStandingSummaryDTO[] = - output.leagueStandingsSummaries.map(standing => ({ - leagueId: standing.leagueId, - leagueName: standing.leagueName, - position: standing.position, + result.leagueStandingsSummaries.map(standing => ({ + leagueId: String(standing.league.id), + leagueName: String(standing.league.name), + position: standing.standing?.position ? Number(standing.standing.position) : null, totalDrivers: standing.totalDrivers, - points: standing.points, + points: standing.standing?.points ? Number(standing.standing.points) : null, })); - const feedItems: DashboardFeedItemSummaryDTO[] = output.feedSummary.items.map(item => ({ + const feedItems: DashboardFeedItemSummaryDTO[] = result.feedSummary.items.map(item => ({ id: item.id, - type: item.type, + type: String(item.type), headline: item.headline, - body: item.body, - timestamp: item.timestamp, - ctaLabel: item.ctaLabel, - ctaHref: item.ctaHref, + body: item.body ?? '', + timestamp: item.timestamp.toISOString(), + ctaLabel: item.ctaLabel ?? '', + ctaHref: item.ctaHref ?? '', })); const feedSummary: DashboardFeedSummaryDTO = { - notificationCount: output.feedSummary.notificationCount, + notificationCount: result.feedSummary.notificationCount, items: feedItems, }; - const friends: DashboardFriendSummaryDTO[] = output.friends.map(friend => ({ - id: friend.id, - name: friend.name, - country: friend.country, + const friends: DashboardFriendSummaryDTO[] = result.friends.map(friend => ({ + id: friend.driver.id, + name: String(friend.driver.name), + country: String(friend.driver.country), avatarUrl: friend.avatarUrl, })); - this.result = { + this.responseModel = { currentDriver, myUpcomingRaces, otherUpcomingRaces, upcomingRaces, - activeLeaguesCount: output.activeLeaguesCount, + activeLeaguesCount: result.activeLeaguesCount, nextRace, recentResults, leagueStandingsSummaries, @@ -105,12 +104,8 @@ export class DashboardOverviewPresenter { }; } - get viewModel(): DashboardOverviewDTO { - if (!this.result) throw new Error('Presenter not presented'); - return this.result; - } - - getViewModel(): DashboardOverviewDTO | null { - return this.result; + getResponseModel(): DashboardOverviewDTO { + if (!this.responseModel) throw new Error('Presenter not presented'); + return this.responseModel; } } diff --git a/apps/api/src/domain/driver/DriverProviders.ts b/apps/api/src/domain/driver/DriverProviders.ts index 9e49d3c0a..a3a3d2e12 100644 --- a/apps/api/src/domain/driver/DriverProviders.ts +++ b/apps/api/src/domain/driver/DriverProviders.ts @@ -23,6 +23,14 @@ import { UpdateDriverProfileUseCase } from '@core/racing/application/use-cases/U import { IsDriverRegisteredForRaceUseCase } from '@core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase'; import { GetProfileOverviewUseCase } from '@core/racing/application/use-cases/GetProfileOverviewUseCase'; +// Import presenters +import { DriverStatsPresenter } from './presenters/DriverStatsPresenter'; +import { DriversLeaderboardPresenter } from './presenters/DriversLeaderboardPresenter'; +import { CompleteOnboardingPresenter } from './presenters/CompleteOnboardingPresenter'; +import { DriverRegistrationStatusPresenter } from './presenters/DriverRegistrationStatusPresenter'; +import { DriverPresenter } from './presenters/DriverPresenter'; +import { DriverProfilePresenter } from './presenters/DriverProfilePresenter'; + // Import concrete in-memory implementations import { InMemoryDriverRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverRepository'; import { InMemoryRankingService } from '@adapters/racing/services/InMemoryRankingService'; diff --git a/apps/api/src/domain/driver/DriverService.ts b/apps/api/src/domain/driver/DriverService.ts index 78a41aa36..50f3a0dd1 100644 --- a/apps/api/src/domain/driver/DriverService.ts +++ b/apps/api/src/domain/driver/DriverService.ts @@ -1,6 +1,12 @@ import { Injectable, Inject } from '@nestjs/common'; import { CompleteOnboardingInputDTO } from './dtos/CompleteOnboardingInputDTO'; import { GetDriverRegistrationStatusQueryDTO } from './dtos/GetDriverRegistrationStatusQueryDTO'; +import { DriversLeaderboardDTO } from './dtos/DriversLeaderboardDTO'; +import { DriverStatsDTO } from './dtos/DriverStatsDTO'; +import { CompleteOnboardingOutputDTO } from './dtos/CompleteOnboardingOutputDTO'; +import { DriverRegistrationStatusDTO } from './dtos/DriverRegistrationStatusDTO'; +import { GetDriverOutputDTO } from './dtos/GetDriverOutputDTO'; +import { GetDriverProfileOutputDTO } from './dtos/GetDriverProfileOutputDTO'; // Use cases import { GetDriversLeaderboardUseCase } from '@core/racing/application/use-cases/GetDriversLeaderboardUseCase'; @@ -51,37 +57,42 @@ export class DriverService { private readonly driverRepository: IDriverRepository, @Inject(LOGGER_TOKEN) private readonly logger: Logger, + private readonly driversLeaderboardPresenter: DriversLeaderboardPresenter, + private readonly driverStatsPresenter: DriverStatsPresenter, + private readonly completeOnboardingPresenter: CompleteOnboardingPresenter, + private readonly driverRegistrationStatusPresenter: DriverRegistrationStatusPresenter, + private readonly driverPresenter: DriverPresenter, + private readonly driverProfilePresenter: DriverProfilePresenter, ) {} - async getDriversLeaderboard(): Promise { + async getDriversLeaderboard(): Promise { this.logger.debug('[DriverService] Fetching drivers leaderboard.'); - const result = await this.getDriversLeaderboardUseCase.execute(); - if (result.isErr()) { - throw new Error(`Failed to fetch drivers leaderboard: ${result.unwrapErr().details.message}`); - } + const result = await this.getDriversLeaderboardUseCase.execute({}); const presenter = new DriversLeaderboardPresenter(); - presenter.reset(); - presenter.present(result.unwrap()); - return presenter; + presenter.present(result); + + return presenter.getResponseModel(); } - async getTotalDrivers(): Promise { + async getTotalDrivers(): Promise { this.logger.debug('[DriverService] Fetching total drivers count.'); - const result = await this.getTotalDriversUseCase.execute(); + const result = await this.getTotalDriversUseCase.execute({}); + if (result.isErr()) { - throw new Error(result.unwrapErr().code); + const error = result.unwrapErr(); + throw new Error(error.details?.message ?? 'Failed to load driver stats'); } - const presenter = new DriverStatsPresenter(); - presenter.reset(); - presenter.present(result.unwrap()); - return presenter; + return this.driverStatsPresenter.getResponseModel(); } - async completeOnboarding(userId: string, input: CompleteOnboardingInputDTO): Promise { + async completeOnboarding( + userId: string, + input: CompleteOnboardingInputDTO, + ): Promise { this.logger.debug('Completing onboarding for user:', userId); const result = await this.completeDriverOnboardingUseCase.execute({ @@ -95,20 +106,14 @@ export class DriverService { }); const presenter = new CompleteOnboardingPresenter(); - presenter.reset(); + presenter.present(result); - if (result.isOk()) { - presenter.present(result.value); - } else { - presenter.presentError(result.error.code); - } - - return presenter; + return presenter.responseModel; } async getDriverRegistrationStatus( query: GetDriverRegistrationStatusQueryDTO, - ): Promise { + ): Promise { this.logger.debug('Checking driver registration status:', query); const result = await this.isDriverRegisteredForRaceUseCase.execute({ @@ -116,77 +121,64 @@ export class DriverService { driverId: query.driverId, }); - if (result.isErr()) { - throw new Error(`Failed to check registration status: ${result.unwrapErr().code}`); - } - const presenter = new DriverRegistrationStatusPresenter(); - presenter.reset(); + presenter.present(result); - const output = result.unwrap(); - presenter.present(output.isRegistered, output.raceId, output.driverId); - - return presenter; + return presenter.responseModel; } - async getCurrentDriver(userId: string): Promise { + async getCurrentDriver(userId: string): Promise { this.logger.debug(`[DriverService] Fetching current driver for userId: ${userId}`); const driver = await this.driverRepository.findById(userId); const presenter = new DriverPresenter(); - presenter.reset(); presenter.present(driver ?? null); - return presenter; + return presenter.responseModel; } async updateDriverProfile( driverId: string, bio?: string, country?: string, - ): Promise { + ): 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}`); presenter.present(null); - return presenter; + return presenter.responseModel; } - presenter.present(result.value); - return presenter; + const updatedDriver = await this.driverRepository.findById(driverId); + presenter.present(updatedDriver ?? null); + return presenter.responseModel; } - async getDriver(driverId: string): Promise { + async getDriver(driverId: string): Promise { this.logger.debug(`[DriverService] Fetching driver for driverId: ${driverId}`); const driver = await this.driverRepository.findById(driverId); const presenter = new DriverPresenter(); - presenter.reset(); presenter.present(driver ?? null); - return presenter; + return presenter.responseModel; } - async getDriverProfile(driverId: string): Promise { + async getDriverProfile(driverId: string): Promise { this.logger.debug(`[DriverService] Fetching driver profile for driverId: ${driverId}`); const result = await this.getProfileOverviewUseCase.execute({ driverId }); - if (result.isErr()) { - throw new Error(`Failed to fetch driver profile: ${result.error.code}`); - } const presenter = new DriverProfilePresenter(); - presenter.reset(); - presenter.present(result.value); + presenter.present(result); - return presenter; + return presenter.responseModel; } } diff --git a/apps/api/src/domain/driver/presenters/CompleteOnboardingPresenter.ts b/apps/api/src/domain/driver/presenters/CompleteOnboardingPresenter.ts index b4ae756e0..bb9a1416f 100644 --- a/apps/api/src/domain/driver/presenters/CompleteOnboardingPresenter.ts +++ b/apps/api/src/domain/driver/presenters/CompleteOnboardingPresenter.ts @@ -1,29 +1,23 @@ -import type { CompleteDriverOnboardingOutputPort } from '@core/racing/application/ports/output/CompleteDriverOnboardingOutputPort'; +import type { + CompleteDriverOnboardingResult, +} from '@core/racing/application/use-cases/CompleteDriverOnboardingUseCase'; import type { CompleteOnboardingOutputDTO } from '../dtos/CompleteOnboardingOutputDTO'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; -export class CompleteOnboardingPresenter { - private result: CompleteOnboardingOutputDTO | null = null; +export class CompleteOnboardingPresenter + implements UseCaseOutputPort +{ + private responseModel: CompleteOnboardingOutputDTO | null = null; - reset(): void { - this.result = null; - } - - present(output: CompleteDriverOnboardingOutputPort): void { - this.result = { + present(result: CompleteDriverOnboardingResult): void { + this.responseModel = { success: true, - driverId: output.driverId, + driverId: result.driver.id, }; } - presentError(errorCode: string): void { - this.result = { - success: false, - errorMessage: errorCode, - }; - } - - get viewModel(): CompleteOnboardingOutputDTO { - if (!this.result) throw new Error('Presenter not presented'); - return this.result; + getResponseModel(): CompleteOnboardingOutputDTO { + if (!this.responseModel) throw new Error('Presenter not presented'); + return this.responseModel; } } diff --git a/apps/api/src/domain/driver/presenters/DriverPresenter.ts b/apps/api/src/domain/driver/presenters/DriverPresenter.ts index 696f2d556..48bfe3a36 100644 --- a/apps/api/src/domain/driver/presenters/DriverPresenter.ts +++ b/apps/api/src/domain/driver/presenters/DriverPresenter.ts @@ -2,19 +2,15 @@ import type { Driver } from '@core/racing/domain/entities/Driver'; import type { GetDriverOutputDTO } from '../dtos/GetDriverOutputDTO'; export class DriverPresenter { - private result: GetDriverOutputDTO | null = null; - - reset(): void { - this.result = null; - } + private responseModel: GetDriverOutputDTO | null = null; present(driver: Driver | null): void { if (!driver) { - this.result = null; + this.responseModel = null; return; } - this.result = { + this.responseModel = { id: driver.id, iracingId: driver.iracingId.toString(), name: driver.name.toString(), @@ -24,7 +20,7 @@ export class DriverPresenter { }; } - get viewModel(): GetDriverOutputDTO | null { - return this.result; + getResponseModel(): GetDriverOutputDTO | null { + return this.responseModel; } } diff --git a/apps/api/src/domain/driver/presenters/DriverProfilePresenter.ts b/apps/api/src/domain/driver/presenters/DriverProfilePresenter.ts index 0437982f2..0d391a130 100644 --- a/apps/api/src/domain/driver/presenters/DriverProfilePresenter.ts +++ b/apps/api/src/domain/driver/presenters/DriverProfilePresenter.ts @@ -1,47 +1,61 @@ -import type { ProfileOverviewOutputPort } from '@core/racing/application/ports/output/ProfileOverviewOutputPort'; +import type { + GetProfileOverviewResult, +} from '@core/racing/application/use-cases/GetProfileOverviewUseCase'; import type { GetDriverProfileOutputDTO } from '../dtos/GetDriverProfileOutputDTO'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; -export class DriverProfilePresenter { - private result: GetDriverProfileOutputDTO | null = null; +export class DriverProfilePresenter + implements UseCaseOutputPort +{ + private responseModel: GetDriverProfileOutputDTO | null = null; - reset(): void { - this.result = null; - } - - present(output: ProfileOverviewOutputPort): void { - this.result = { - currentDriver: output.driver + present(result: GetProfileOverviewResult): void { + this.responseModel = { + currentDriver: result.driverInfo ? { - id: output.driver.id, - name: output.driver.name, - country: output.driver.country, - avatarUrl: output.driver.avatarUrl, - iracingId: output.driver.iracingId, - joinedAt: output.driver.joinedAt.toISOString(), - rating: output.driver.rating, - globalRank: output.driver.globalRank, - consistency: output.driver.consistency, - bio: output.driver.bio, - totalDrivers: output.driver.totalDrivers, + id: result.driverInfo.driver.id, + name: result.driverInfo.driver.name.toString(), + country: result.driverInfo.driver.country.toString(), + avatarUrl: this.getAvatarUrl(result.driverInfo.driver.id) || '', + iracingId: result.driverInfo.driver.iracingId.toString(), + joinedAt: result.driverInfo.driver.joinedAt.toDate().toISOString(), + rating: result.driverInfo.rating, + globalRank: result.driverInfo.globalRank, + consistency: result.driverInfo.consistency, + bio: result.driverInfo.driver.bio?.toString() || null, + totalDrivers: result.driverInfo.totalDrivers, } : null, - stats: output.stats, - finishDistribution: output.finishDistribution, - teamMemberships: output.teamMemberships.map(membership => ({ - teamId: membership.teamId, - teamName: membership.teamName, - teamTag: membership.teamTag, - role: membership.role, - joinedAt: membership.joinedAt.toISOString(), - isCurrent: membership.isCurrent, + stats: result.stats, + finishDistribution: result.finishDistribution, + teamMemberships: result.teamMemberships.map(membership => ({ + teamId: membership.team.id, + teamName: membership.team.name.toString(), + teamTag: membership.team.tag.toString(), + role: membership.membership.role, + joinedAt: membership.membership.joinedAt.toISOString(), + isCurrent: true, // TODO: check membership status })), - socialSummary: output.socialSummary, - extendedProfile: output.extendedProfile, + socialSummary: { + friendsCount: result.socialSummary.friendsCount, + friends: result.socialSummary.friends.map(friend => ({ + id: friend.id, + name: friend.name.toString(), + country: friend.country.toString(), + avatarUrl: '', // TODO: get avatar + })), + }, + extendedProfile: result.extendedProfile as any, }; } - get viewModel(): GetDriverProfileOutputDTO { - if (!this.result) throw new Error('Presenter not presented'); - return this.result; + getResponseModel(): GetDriverProfileOutputDTO { + if (!this.responseModel) throw new Error('Presenter not presented'); + return this.responseModel; + } + + private getAvatarUrl(driverId: string): string | undefined { + // Avatar resolution is delegated to infrastructure; keep as-is for now. + return undefined; } } diff --git a/apps/api/src/domain/driver/presenters/DriverRegistrationStatusPresenter.ts b/apps/api/src/domain/driver/presenters/DriverRegistrationStatusPresenter.ts index 458938353..fc64ef644 100644 --- a/apps/api/src/domain/driver/presenters/DriverRegistrationStatusPresenter.ts +++ b/apps/api/src/domain/driver/presenters/DriverRegistrationStatusPresenter.ts @@ -1,25 +1,24 @@ +import type { + IsDriverRegisteredForRaceResult, +} from '@core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase'; import { DriverRegistrationStatusDTO } from '../dtos/DriverRegistrationStatusDTO'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; -export class DriverRegistrationStatusPresenter { - private result: DriverRegistrationStatusDTO | null = null; +export class DriverRegistrationStatusPresenter + implements UseCaseOutputPort +{ + private responseModel: DriverRegistrationStatusDTO | null = null; - reset(): void { - this.result = null; - } - - present(isRegistered: boolean, raceId: string, driverId: string): void { - this.result = { - isRegistered, - raceId, - driverId, + present(result: IsDriverRegisteredForRaceResult): void { + this.responseModel = { + isRegistered: result.isRegistered, + raceId: result.raceId, + driverId: result.driverId, }; } - get viewModel(): DriverRegistrationStatusDTO { - if (!this.result) { - throw new Error('Presenter not presented'); - } - - return this.result; + getResponseModel(): DriverRegistrationStatusDTO { + if (!this.responseModel) throw new Error('Presenter not presented'); + return this.responseModel; } } diff --git a/apps/api/src/domain/driver/presenters/DriverStatsPresenter.test.ts b/apps/api/src/domain/driver/presenters/DriverStatsPresenter.test.ts index 500c9bc98..b15805cae 100644 --- a/apps/api/src/domain/driver/presenters/DriverStatsPresenter.test.ts +++ b/apps/api/src/domain/driver/presenters/DriverStatsPresenter.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; +import { Result } from '@core/shared/application/Result'; import { DriverStatsPresenter } from './DriverStatsPresenter'; -import type { TotalDriversResultDTO } from '../../../../../core/racing/application/presenters/ITotalDriversPresenter'; +import type { GetTotalDriversResult } from '../../../../../core/racing/application/use-cases/GetTotalDriversUseCase'; describe('DriverStatsPresenter', () => { let presenter: DriverStatsPresenter; @@ -10,16 +11,18 @@ describe('DriverStatsPresenter', () => { }); describe('present', () => { - it('should map core DTO to API view model correctly', () => { - const dto: TotalDriversResultDTO = { + it('should map core result to API response model correctly', () => { + const output: GetTotalDriversResult = { totalDrivers: 42, }; - presenter.present(dto); + const result = Result.ok(output); - const result = presenter.viewModel; + presenter.present(result); - expect(result).toEqual({ + const response = presenter.responseModel; + + expect(response).toEqual({ totalDrivers: 42, }); }); @@ -27,15 +30,17 @@ describe('DriverStatsPresenter', () => { describe('reset', () => { it('should reset the result', () => { - const dto: TotalDriversResultDTO = { + const output: GetTotalDriversResult = { totalDrivers: 10, }; - presenter.present(dto); - expect(presenter.viewModel).toBeDefined(); + const result = Result.ok(output); + + presenter.present(result); + expect(presenter.responseModel).toBeDefined(); presenter.reset(); - expect(() => presenter.viewModel).toThrow('Presenter not presented'); + expect(() => presenter.responseModel).toThrow('Presenter not presented'); }); }); }); \ No newline at end of file diff --git a/apps/api/src/domain/driver/presenters/DriverStatsPresenter.ts b/apps/api/src/domain/driver/presenters/DriverStatsPresenter.ts index ead17bf41..a106be153 100644 --- a/apps/api/src/domain/driver/presenters/DriverStatsPresenter.ts +++ b/apps/api/src/domain/driver/presenters/DriverStatsPresenter.ts @@ -1,21 +1,22 @@ import { DriverStatsDTO } from '../dtos/DriverStatsDTO'; -import type { TotalDriversOutputPort } from '../../../../../core/racing/application/ports/output/TotalDriversOutputPort'; +import type { + GetTotalDriversResult, +} from '@core/racing/application/use-cases/GetTotalDriversUseCase'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; -export class DriverStatsPresenter { - private result: DriverStatsDTO | null = null; +export class DriverStatsPresenter + implements UseCaseOutputPort +{ + private responseModel: DriverStatsDTO | null = null; - reset() { - this.result = null; - } - - present(output: TotalDriversOutputPort) { - this.result = { - totalDrivers: output.totalDrivers, + present(result: GetTotalDriversResult): void { + this.responseModel = { + totalDrivers: result.totalDrivers, }; } - get viewModel(): DriverStatsDTO { - if (!this.result) throw new Error('Presenter not presented'); - return this.result; + getResponseModel(): DriverStatsDTO { + if (!this.responseModel) throw new Error('Presenter not presented'); + return this.responseModel; } } \ No newline at end of file diff --git a/apps/api/src/domain/driver/presenters/DriversLeaderboardPresenter.test.ts b/apps/api/src/domain/driver/presenters/DriversLeaderboardPresenter.test.ts index 35d157bc7..164d349c9 100644 --- a/apps/api/src/domain/driver/presenters/DriversLeaderboardPresenter.test.ts +++ b/apps/api/src/domain/driver/presenters/DriversLeaderboardPresenter.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; +import { Result } from '@core/shared/application/Result'; import { DriversLeaderboardPresenter } from './DriversLeaderboardPresenter'; -import type { DriversLeaderboardResultDTO } from '../../../../../core/racing/application/presenters/IDriversLeaderboardPresenter'; +import type { GetDriversLeaderboardResult } from '../../../../../core/racing/application/use-cases/GetDriversLeaderboardUseCase'; describe('DriversLeaderboardPresenter', () => { let presenter: DriversLeaderboardPresenter; @@ -10,41 +11,50 @@ describe('DriversLeaderboardPresenter', () => { }); describe('present', () => { - it('should map core DTO to API view model correctly', () => { - const dto: DriversLeaderboardResultDTO = { - drivers: [ + it('should map core result to API response model correctly', () => { + const coreResult: GetDriversLeaderboardResult = { + items: [ { - id: 'driver-1', - name: 'Driver One', - country: 'US', - iracingId: '12345', - joinedAt: new Date('2023-01-01'), + driver: { + id: 'driver-1', + name: 'Driver One' as any, + country: 'US' as any, + } as any, + rating: 2500, + skillLevel: 'advanced' as any, + racesCompleted: 50, + wins: 10, + podiums: 20, + isActive: true, + rank: 1, + avatarUrl: 'https://example.com/avatar1.png', }, { - id: 'driver-2', - name: 'Driver Two', - country: 'DE', - iracingId: '67890', - joinedAt: new Date('2023-01-02'), + driver: { + id: 'driver-2', + name: 'Driver Two' as any, + country: 'DE' as any, + } as any, + rating: 2400, + skillLevel: 'intermediate' as any, + racesCompleted: 40, + wins: 5, + podiums: 15, + isActive: true, + rank: 2, + avatarUrl: 'https://example.com/avatar2.png', }, ], - rankings: [ - { driverId: 'driver-1', rating: 2500, overallRank: 1 }, - { driverId: 'driver-2', rating: 2400, overallRank: 2 }, - ], - stats: { - 'driver-1': { racesCompleted: 50, wins: 10, podiums: 20 }, - 'driver-2': { racesCompleted: 40, wins: 5, podiums: 15 }, - }, - avatarUrls: { - 'driver-1': 'https://example.com/avatar1.png', - 'driver-2': 'https://example.com/avatar2.png', - }, + totalRaces: 90, + totalWins: 15, + activeCount: 2, }; - presenter.present(dto); + const result = Result.ok(coreResult); - const result = presenter.viewModel; + presenter.present(result); + + const api = presenter.responseModel; expect(result.drivers).toHaveLength(2); expect(result.drivers[0]).toEqual({ diff --git a/apps/api/src/domain/driver/presenters/DriversLeaderboardPresenter.ts b/apps/api/src/domain/driver/presenters/DriversLeaderboardPresenter.ts index 10e67278f..db4f426b7 100644 --- a/apps/api/src/domain/driver/presenters/DriversLeaderboardPresenter.ts +++ b/apps/api/src/domain/driver/presenters/DriversLeaderboardPresenter.ts @@ -1,36 +1,44 @@ +import type { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import { DriversLeaderboardDTO } from '../dtos/DriversLeaderboardDTO'; -import type { DriversLeaderboardOutputPort } from '../../../../../core/racing/application/ports/output/DriversLeaderboardOutputPort'; +import type { + GetDriversLeaderboardResult, + GetDriversLeaderboardErrorCode, +} from '@core/racing/application/use-cases/GetDriversLeaderboardUseCase'; + +export type DriversLeaderboardApplicationError = ApplicationErrorCode< + GetDriversLeaderboardErrorCode, + { message: string } +>; export class DriversLeaderboardPresenter { - private result: DriversLeaderboardDTO | null = null; + present( + result: Result, + ): DriversLeaderboardDTO { + if (result.isErr()) { + const error = result.unwrapErr(); + throw new Error(error.details?.message ?? 'Failed to load drivers leaderboard'); + } - reset(): void { - this.result = null; - } + const output = result.unwrap(); - present(output: DriversLeaderboardOutputPort): void { - this.result = { - drivers: output.drivers.map(driver => ({ - id: driver.id, - name: driver.name, - rating: driver.rating, - skillLevel: driver.skillLevel, - nationality: driver.nationality, - racesCompleted: driver.racesCompleted, - wins: driver.wins, - podiums: driver.podiums, - isActive: driver.isActive, - rank: driver.rank, - avatarUrl: driver.avatarUrl, + return { + drivers: output.items.map(item => ({ + id: item.driver.id, + name: item.driver.name.toString(), + rating: item.rating, + skillLevel: item.skillLevel, + nationality: item.driver.country.toString(), + racesCompleted: item.racesCompleted, + wins: item.wins, + podiums: item.podiums, + isActive: item.isActive, + rank: item.rank, + avatarUrl: item.avatarUrl, })), - totalRaces: output.totalRaces, - totalWins: output.totalWins, - activeCount: output.activeCount, + totalRaces: output.items.reduce((sum, d) => sum + d.racesCompleted, 0), + totalWins: output.items.reduce((sum, d) => sum + d.wins, 0), + activeCount: output.items.filter(d => d.isActive).length, }; } - - get viewModel(): DriversLeaderboardDTO { - if (!this.result) throw new Error('Presenter not presented'); - return this.result; - } } \ No newline at end of file diff --git a/apps/api/src/domain/league/LeagueProviders.ts b/apps/api/src/domain/league/LeagueProviders.ts index 431df9199..f499bf456 100644 --- a/apps/api/src/domain/league/LeagueProviders.ts +++ b/apps/api/src/domain/league/LeagueProviders.ts @@ -50,6 +50,9 @@ import { GetLeagueAdminPermissionsUseCase } from '@core/racing/application/use-c import { GetLeagueWalletUseCase } from '@core/racing/application/use-cases/GetLeagueWalletUseCase'; import { WithdrawFromLeagueWalletUseCase } from '@core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase'; +// Import presenters +import { AllLeaguesWithCapacityPresenter } from './presenters/AllLeaguesWithCapacityPresenter'; + // Define injection tokens export const LEAGUE_REPOSITORY_TOKEN = 'ILeagueRepository'; export const LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN = 'ILeagueMembershipRepository'; @@ -137,8 +140,18 @@ export const LeagueProviders: Provider[] = [ provide: LOGGER_TOKEN, useClass: ConsoleLogger, }, + // Presenters + { + provide: 'AllLeaguesWithCapacityPresenter', + useClass: AllLeaguesWithCapacityPresenter, + }, // Use cases - GetAllLeaguesWithCapacityUseCase, + { + provide: GetAllLeaguesWithCapacityUseCase, + useFactory: (leagueRepo: ILeagueRepository, membershipRepo: ILeagueMembershipRepository, presenter: AllLeaguesWithCapacityPresenter) => + new GetAllLeaguesWithCapacityUseCase(leagueRepo, membershipRepo, presenter), + inject: [LEAGUE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, 'AllLeaguesWithCapacityPresenter'], + }, { provide: GET_LEAGUE_STANDINGS_USE_CASE, useClass: GetLeagueStandingsUseCaseImpl, diff --git a/apps/api/src/domain/league/LeagueService.ts b/apps/api/src/domain/league/LeagueService.ts index 8412e0808..85a2b32b1 100644 --- a/apps/api/src/domain/league/LeagueService.ts +++ b/apps/api/src/domain/league/LeagueService.ts @@ -135,9 +135,7 @@ export class LeagueService { if (result.isErr()) { throw new Error(result.unwrapErr().code); } - const presenter = new AllLeaguesWithCapacityPresenter(); - presenter.present(result.unwrap()); - return presenter.getViewModel()!; + return this.getAllLeaguesWithCapacityUseCase.outputPort.present(result); } async getTotalLeagues(): Promise { diff --git a/apps/api/src/domain/league/presenters/AllLeaguesWithCapacityPresenter.ts b/apps/api/src/domain/league/presenters/AllLeaguesWithCapacityPresenter.ts index b16f3f94a..deb92d644 100644 --- a/apps/api/src/domain/league/presenters/AllLeaguesWithCapacityPresenter.ts +++ b/apps/api/src/domain/league/presenters/AllLeaguesWithCapacityPresenter.ts @@ -1,31 +1,25 @@ -import type { AllLeaguesWithCapacityOutputPort } from '@core/racing/application/ports/output/AllLeaguesWithCapacityOutputPort'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import { Result } from '@core/shared/application/Result'; import { AllLeaguesWithCapacityDTO, LeagueWithCapacityDTO } from '../dtos/AllLeaguesWithCapacityDTO'; +import type { GetAllLeaguesWithCapacityResult } from '@core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -export class AllLeaguesWithCapacityPresenter { - private result: AllLeaguesWithCapacityDTO | null = null; - - reset() { - this.result = null; - } - - present(output: AllLeaguesWithCapacityOutputPort) { +export class AllLeaguesWithCapacityPresenter implements UseCaseOutputPort { + present(result: Result>): AllLeaguesWithCapacityDTO { + const output = result.unwrap(); const leagues: LeagueWithCapacityDTO[] = output.leagues.map(league => ({ - id: league.id, - name: league.name, - description: league.description, - ownerId: league.ownerId, - settings: { maxDrivers: league.settings.maxDrivers || 0 }, - createdAt: league.createdAt.toISOString(), - usedSlots: output.memberCounts[league.id] || 0, - socialLinks: league.socialLinks, + id: league.league.id.toString(), + name: league.league.name.toString(), + description: league.league.description?.toString() || '', + ownerId: league.league.ownerId.toString(), + settings: { maxDrivers: league.maxDrivers }, + createdAt: league.league.createdAt.toDate().toISOString(), + usedSlots: league.currentDrivers, + socialLinks: league.league.socialLinks || {}, })); - this.result = { + return { leagues, totalCount: leagues.length, }; } - - getViewModel(): AllLeaguesWithCapacityDTO | 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 1cecf4e33..7b8f6716f 100644 --- a/apps/api/src/domain/media/MediaController.test.ts +++ b/apps/api/src/domain/media/MediaController.test.ts @@ -5,10 +5,16 @@ import { MediaService } from './MediaService'; import type { Response } from 'express'; import { RequestAvatarGenerationInputDTO } from './dtos/RequestAvatarGenerationInputDTO'; import { UploadMediaInputDTO } from './dtos/UploadMediaInputDTO'; +import { RequestAvatarGenerationOutputDTO } from './dtos/RequestAvatarGenerationOutputDTO'; +import { UploadMediaOutputDTO } from './dtos/UploadMediaOutputDTO'; +import { GetMediaOutputDTO } from './dtos/GetMediaOutputDTO'; +import { DeleteMediaOutputDTO } from './dtos/DeleteMediaOutputDTO'; +import { GetAvatarOutputDTO } from './dtos/GetAvatarOutputDTO'; +import { UpdateAvatarOutputDTO } from './dtos/UpdateAvatarOutputDTO'; describe('MediaController', () => { let controller: MediaController; - let service: ReturnType>; + let service: jest.Mocked; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -29,153 +35,201 @@ describe('MediaController', () => { }).compile(); controller = module.get(MediaController); - service = vi.mocked(module.get(MediaService)); + service = module.get(MediaService) as jest.Mocked; }); + const createMockResponse = (): Response => { + const res: Partial = {}; + res.status = vi.fn().mockReturnValue(res as Response); + res.json = vi.fn().mockReturnValue(res as Response); + return res as Response; + }; + describe('requestAvatarGeneration', () => { it('should request avatar generation and return 201 on success', async () => { - const input: RequestAvatarGenerationInputDTO = { driverId: 'driver-123' }; - const viewModel = { success: true, jobId: 'job-123' } as any; - service.requestAvatarGeneration.mockResolvedValue({ viewModel } as any); + const input: RequestAvatarGenerationInputDTO = { + userId: 'user-123', + facePhotoData: 'photo-data', + suitColor: 'red', + }; + const dto: RequestAvatarGenerationOutputDTO = { + success: true, + requestId: 'req-123', + avatarUrls: ['https://example.com/avatar.png'], + }; + service.requestAvatarGeneration.mockResolvedValue(dto); - const mockRes: ReturnType> = { - status: vi.fn().mockReturnThis(), - json: vi.fn(), - } as unknown as ReturnType>; + const res = createMockResponse(); - await controller.requestAvatarGeneration(input, mockRes); + await controller.requestAvatarGeneration(input, res); expect(service.requestAvatarGeneration).toHaveBeenCalledWith(input); - expect(mockRes.status).toHaveBeenCalledWith(201); - expect(mockRes.json).toHaveBeenCalledWith(viewModel); + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith(dto); }); it('should return 400 on failure', async () => { - const input: RequestAvatarGenerationInputDTO = { driverId: 'driver-123' }; - const viewModel = { success: false, error: 'Error' } as any; - service.requestAvatarGeneration.mockResolvedValue({ viewModel } as any); + const input: RequestAvatarGenerationInputDTO = { + userId: 'user-123', + facePhotoData: 'photo-data', + suitColor: 'red', + }; + const dto: RequestAvatarGenerationOutputDTO = { + success: false, + errorMessage: 'Error', + }; + service.requestAvatarGeneration.mockResolvedValue(dto); - const mockRes: ReturnType> = { - status: vi.fn().mockReturnThis(), - json: vi.fn(), - } as unknown as ReturnType>; + const res = createMockResponse(); - await controller.requestAvatarGeneration(input, mockRes); + await controller.requestAvatarGeneration(input, res); - expect(mockRes.status).toHaveBeenCalledWith(400); - expect(mockRes.json).toHaveBeenCalledWith(viewModel); + expect(service.requestAvatarGeneration).toHaveBeenCalledWith(input); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(dto); }); }); describe('uploadMedia', () => { 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 viewModel = { success: true, mediaId: 'media-123' } as any; - service.uploadMedia.mockResolvedValue({ viewModel } as any); + const input: UploadMediaInputDTO = { type: 'image' } as UploadMediaInputDTO; + const dto: UploadMediaOutputDTO = { + success: true, + mediaId: 'media-123', + url: 'https://example.com/file.jpg', + }; + service.uploadMedia.mockResolvedValue(dto); - const mockRes: ReturnType> = { - status: vi.fn().mockReturnThis(), - json: vi.fn(), - } as unknown as ReturnType>; + const res = createMockResponse(); - await controller.uploadMedia(file, input, mockRes); + await controller.uploadMedia(file, input, res); expect(service.uploadMedia).toHaveBeenCalledWith({ ...input, file }); - expect(mockRes.status).toHaveBeenCalledWith(201); - expect(mockRes.json).toHaveBeenCalledWith(viewModel); + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith(dto); + }); + + it('should return 400 when upload fails', async () => { + const file: Express.Multer.File = { filename: 'file.jpg' } as Express.Multer.File; + const input: UploadMediaInputDTO = { type: 'image' } as UploadMediaInputDTO; + const dto: UploadMediaOutputDTO = { + success: false, + error: 'Upload failed', + }; + service.uploadMedia.mockResolvedValue(dto); + + const res = createMockResponse(); + + await controller.uploadMedia(file, input, res); + + expect(service.uploadMedia).toHaveBeenCalledWith({ ...input, file }); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(dto); }); }); describe('getMedia', () => { it('should return media if found', async () => { const mediaId = 'media-123'; - const viewModel = { id: mediaId, url: 'url' } as any; - service.getMedia.mockResolvedValue({ viewModel } as any); + const dto: GetMediaOutputDTO = { + id: mediaId, + url: 'https://example.com/file.jpg', + type: 'image', + category: 'avatar', + uploadedAt: new Date(), + size: 123, + }; + service.getMedia.mockResolvedValue(dto); - const mockRes: ReturnType> = { - status: vi.fn().mockReturnThis(), - json: vi.fn(), - } as unknown as ReturnType>; + const res = createMockResponse(); - await controller.getMedia(mediaId, mockRes); + await controller.getMedia(mediaId, res); expect(service.getMedia).toHaveBeenCalledWith(mediaId); - expect(mockRes.status).toHaveBeenCalledWith(200); - expect(mockRes.json).toHaveBeenCalledWith(viewModel); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(dto); }); it('should return 404 if not found', async () => { const mediaId = 'media-123'; - service.getMedia.mockResolvedValue({ viewModel: null } as any); + service.getMedia.mockResolvedValue(null); - const mockRes: ReturnType> = { - status: vi.fn().mockReturnThis(), - json: vi.fn(), - } as unknown as ReturnType>; + const res = createMockResponse(); - await controller.getMedia(mediaId, mockRes); + await controller.getMedia(mediaId, res); - expect(mockRes.status).toHaveBeenCalledWith(404); - expect(mockRes.json).toHaveBeenCalledWith({ error: 'Media not found' }); + expect(service.getMedia).toHaveBeenCalledWith(mediaId); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Media not found' }); }); }); describe('deleteMedia', () => { - it('should delete media', async () => { + it('should delete media and return result', async () => { const mediaId = 'media-123'; - const viewModel = { success: true } as any; - service.deleteMedia.mockResolvedValue({ viewModel } as any); + const dto: DeleteMediaOutputDTO = { + success: true, + }; + service.deleteMedia.mockResolvedValue(dto); - const mockRes: ReturnType> = { - status: vi.fn().mockReturnThis(), - json: vi.fn(), - } as unknown as ReturnType>; + const res = createMockResponse(); - await controller.deleteMedia(mediaId, mockRes); + await controller.deleteMedia(mediaId, res); expect(service.deleteMedia).toHaveBeenCalledWith(mediaId); - expect(mockRes.status).toHaveBeenCalledWith(200); - expect(mockRes.json).toHaveBeenCalledWith(viewModel); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(dto); }); }); describe('getAvatar', () => { it('should return avatar if found', async () => { const driverId = 'driver-123'; - const result = { url: 'avatar.jpg' }; - service.getAvatar.mockResolvedValue(result); + const dto: GetAvatarOutputDTO = { + avatarUrl: 'https://example.com/avatar.png', + }; + service.getAvatar.mockResolvedValue(dto); - const mockRes: ReturnType> = { - status: vi.fn().mockReturnThis(), - json: vi.fn(), - } as unknown as ReturnType>; + const res = createMockResponse(); - await controller.getAvatar(driverId, mockRes); + await controller.getAvatar(driverId, res); expect(service.getAvatar).toHaveBeenCalledWith(driverId); - expect(mockRes.status).toHaveBeenCalledWith(200); - expect(mockRes.json).toHaveBeenCalledWith(result); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(dto); + }); + + it('should return 404 when avatar not found', async () => { + const driverId = 'driver-123'; + service.getAvatar.mockResolvedValue(null); + + const res = createMockResponse(); + + await controller.getAvatar(driverId, res); + + expect(service.getAvatar).toHaveBeenCalledWith(driverId); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Avatar not found' }); }); }); describe('updateAvatar', () => { - it('should update avatar', async () => { + it('should update avatar and return result', async () => { const driverId = 'driver-123'; - const input = { url: 'new-avatar.jpg' }; - const result = { success: true }; - service.updateAvatar.mockResolvedValue(result); + const input = { mediaUrl: 'https://example.com/new-avatar.png' } as UpdateAvatarOutputDTO; + const dto: UpdateAvatarOutputDTO = { + success: true, + }; + service.updateAvatar.mockResolvedValue(dto); - const mockRes: ReturnType> = { - status: vi.fn().mockReturnThis(), - json: vi.fn(), - } as unknown as ReturnType>; + const res = createMockResponse(); - await controller.updateAvatar(driverId, input, mockRes); + await controller.updateAvatar(driverId, input as any, res); - expect(service.updateAvatar).toHaveBeenCalledWith(driverId, input); - expect(mockRes.status).toHaveBeenCalledWith(200); - expect(mockRes.json).toHaveBeenCalledWith(result); + expect(service.updateAvatar).toHaveBeenCalledWith(driverId, input as any); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(dto); }); }); -}); \ No newline at end of file +}); diff --git a/apps/api/src/domain/media/MediaController.ts b/apps/api/src/domain/media/MediaController.ts index 0597f78ed..f04a3d82b 100644 --- a/apps/api/src/domain/media/MediaController.ts +++ b/apps/api/src/domain/media/MediaController.ts @@ -29,13 +29,12 @@ export class MediaController { @Body() input: RequestAvatarGenerationInput, @Res() res: Response, ): Promise { - const presenter = await this.mediaService.requestAvatarGeneration(input); - const viewModel = presenter.viewModel; + const dto: RequestAvatarGenerationOutputDTO = await this.mediaService.requestAvatarGeneration(input); - if (viewModel.success) { - res.status(HttpStatus.CREATED).json(viewModel); + if (dto.success) { + res.status(HttpStatus.CREATED).json(dto); } else { - res.status(HttpStatus.BAD_REQUEST).json(viewModel); + res.status(HttpStatus.BAD_REQUEST).json(dto); } } @@ -49,13 +48,12 @@ export class MediaController { @Body() input: UploadMediaInput, @Res() res: Response, ): Promise { - const presenter = await this.mediaService.uploadMedia({ ...input, file }); - const viewModel = presenter.viewModel; + const dto: UploadMediaOutputDTO = await this.mediaService.uploadMedia({ ...input, file }); - if (viewModel.success) { - res.status(HttpStatus.CREATED).json(viewModel); + if (dto.success) { + res.status(HttpStatus.CREATED).json(dto); } else { - res.status(HttpStatus.BAD_REQUEST).json(viewModel); + res.status(HttpStatus.BAD_REQUEST).json(dto); } } @@ -67,11 +65,10 @@ export class MediaController { @Param('mediaId') mediaId: string, @Res() res: Response, ): Promise { - const presenter = await this.mediaService.getMedia(mediaId); - const viewModel = presenter.viewModel; + const dto: GetMediaOutputDTO | null = await this.mediaService.getMedia(mediaId); - if (viewModel) { - res.status(HttpStatus.OK).json(viewModel); + if (dto) { + res.status(HttpStatus.OK).json(dto); } else { res.status(HttpStatus.NOT_FOUND).json({ error: 'Media not found' }); } @@ -85,10 +82,9 @@ export class MediaController { @Param('mediaId') mediaId: string, @Res() res: Response, ): Promise { - const presenter = await this.mediaService.deleteMedia(mediaId); - const viewModel = presenter.viewModel; + const dto: DeleteMediaOutputDTO = await this.mediaService.deleteMedia(mediaId); - res.status(HttpStatus.OK).json(viewModel); + res.status(HttpStatus.OK).json(dto); } @Get('avatar/:driverId') @@ -99,11 +95,10 @@ export class MediaController { @Param('driverId') driverId: string, @Res() res: Response, ): Promise { - const presenter = await this.mediaService.getAvatar(driverId); - const viewModel = presenter.viewModel; + const dto: GetAvatarOutputDTO | null = await this.mediaService.getAvatar(driverId); - if (viewModel) { - res.status(HttpStatus.OK).json(viewModel); + if (dto) { + res.status(HttpStatus.OK).json(dto); } else { res.status(HttpStatus.NOT_FOUND).json({ error: 'Avatar not found' }); } @@ -118,9 +113,8 @@ export class MediaController { @Body() input: UpdateAvatarInput, @Res() res: Response, ): Promise { - const presenter = await this.mediaService.updateAvatar(driverId, input); - const viewModel = presenter.viewModel; + const dto: UpdateAvatarOutputDTO = await this.mediaService.updateAvatar(driverId, input); - res.status(HttpStatus.OK).json(viewModel); + res.status(HttpStatus.OK).json(dto); } } diff --git a/apps/api/src/domain/media/MediaService.ts b/apps/api/src/domain/media/MediaService.ts index ea7474986..131391029 100644 --- a/apps/api/src/domain/media/MediaService.ts +++ b/apps/api/src/domain/media/MediaService.ts @@ -2,6 +2,12 @@ import { Injectable, Inject } from '@nestjs/common'; import type { RequestAvatarGenerationInputDTO } from './dtos/RequestAvatarGenerationInputDTO'; import type { UploadMediaInputDTO } from './dtos/UploadMediaInputDTO'; import type { UpdateAvatarInputDTO } from './dtos/UpdateAvatarInputDTO'; +import type { RequestAvatarGenerationOutputDTO } from './dtos/RequestAvatarGenerationOutputDTO'; +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 { UpdateAvatarOutputDTO } from './dtos/UpdateAvatarOutputDTO'; import type { RacingSuitColor } from '@core/media/domain/types/AvatarGenerationRequest'; type RequestAvatarGenerationInput = RequestAvatarGenerationInputDTO; @@ -32,89 +38,116 @@ import { DELETE_MEDIA_USE_CASE_TOKEN, GET_AVATAR_USE_CASE_TOKEN, UPDATE_AVATAR_USE_CASE_TOKEN, - LOGGER_TOKEN + LOGGER_TOKEN, } from './MediaProviders'; import type { Logger } from '@core/shared/application'; @Injectable() export class MediaService { constructor( - @Inject(REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN) private readonly requestAvatarGenerationUseCase: RequestAvatarGenerationUseCase, - @Inject(UPLOAD_MEDIA_USE_CASE_TOKEN) private readonly uploadMediaUseCase: UploadMediaUseCase, - @Inject(GET_MEDIA_USE_CASE_TOKEN) private readonly getMediaUseCase: GetMediaUseCase, - @Inject(DELETE_MEDIA_USE_CASE_TOKEN) private readonly deleteMediaUseCase: DeleteMediaUseCase, - @Inject(GET_AVATAR_USE_CASE_TOKEN) private readonly getAvatarUseCase: GetAvatarUseCase, - @Inject(UPDATE_AVATAR_USE_CASE_TOKEN) private readonly updateAvatarUseCase: UpdateAvatarUseCase, - @Inject(LOGGER_TOKEN) private readonly logger: Logger, + @Inject(REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN) + private readonly requestAvatarGenerationUseCase: RequestAvatarGenerationUseCase, + @Inject(UPLOAD_MEDIA_USE_CASE_TOKEN) + private readonly uploadMediaUseCase: UploadMediaUseCase, + @Inject(GET_MEDIA_USE_CASE_TOKEN) + private readonly getMediaUseCase: GetMediaUseCase, + @Inject(DELETE_MEDIA_USE_CASE_TOKEN) + private readonly deleteMediaUseCase: DeleteMediaUseCase, + @Inject(GET_AVATAR_USE_CASE_TOKEN) + private readonly getAvatarUseCase: GetAvatarUseCase, + @Inject(UPDATE_AVATAR_USE_CASE_TOKEN) + private readonly updateAvatarUseCase: UpdateAvatarUseCase, + @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(); - await this.requestAvatarGenerationUseCase.execute({ + presenter.reset(); + + const result = await this.requestAvatarGenerationUseCase.execute({ userId: input.userId, facePhotoData: input.facePhotoData, suitColor: input.suitColor as RacingSuitColor, - }, presenter); + }); - return presenter; + presenter.present(result); + + return presenter.responseModel; } - async uploadMedia(input: UploadMediaInput & { file: Express.Multer.File }): Promise { + async uploadMedia( + input: UploadMediaInput & { file: Express.Multer.File } & { userId?: string; metadata?: Record }, + ): Promise { this.logger.debug('[MediaService] Uploading media.'); const presenter = new UploadMediaPresenter(); + presenter.reset(); - await this.uploadMediaUseCase.execute({ + const result = await this.uploadMediaUseCase.execute({ file: input.file, - uploadedBy: input.userId, // Assuming userId is the uploader + uploadedBy: input.userId ?? '', metadata: input.metadata, - }, presenter); + }); - return presenter; + presenter.present(result); + + return presenter.responseModel; } - async getMedia(mediaId: string): Promise { + async getMedia(mediaId: string): Promise { this.logger.debug(`[MediaService] Getting media: ${mediaId}`); const presenter = new GetMediaPresenter(); + presenter.reset(); - await this.getMediaUseCase.execute({ mediaId }, presenter); + const result = await this.getMediaUseCase.execute({ mediaId }); + presenter.present(result); - return presenter; + return presenter.responseModel; } - async deleteMedia(mediaId: string): Promise { + async deleteMedia(mediaId: string): Promise { this.logger.debug(`[MediaService] Deleting media: ${mediaId}`); const presenter = new DeleteMediaPresenter(); + presenter.reset(); - await this.deleteMediaUseCase.execute({ mediaId }, presenter); + const result = await this.deleteMediaUseCase.execute({ mediaId }); + presenter.present(result); - return presenter; + return presenter.responseModel; } - async getAvatar(driverId: string): Promise { + async getAvatar(driverId: string): Promise { this.logger.debug(`[MediaService] Getting avatar for driver: ${driverId}`); const presenter = new GetAvatarPresenter(); + presenter.reset(); - await this.getAvatarUseCase.execute({ driverId }, presenter); + const result = await this.getAvatarUseCase.execute({ driverId }); + presenter.present(result); - return presenter; + return presenter.responseModel; } - 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({ + presenter.reset(); + + const result = await this.updateAvatarUseCase.execute({ driverId, mediaUrl: input.mediaUrl, - }, presenter); - - return presenter; + }); + + presenter.present(result); + + return presenter.responseModel; } } diff --git a/apps/api/src/domain/media/presenters/DeleteMediaPresenter.ts b/apps/api/src/domain/media/presenters/DeleteMediaPresenter.ts index d57a1da68..d3376da32 100644 --- a/apps/api/src/domain/media/presenters/DeleteMediaPresenter.ts +++ b/apps/api/src/domain/media/presenters/DeleteMediaPresenter.ts @@ -1,21 +1,50 @@ -import type { IDeleteMediaPresenter, DeleteMediaResult } from '@core/media/application/presenters/IDeleteMediaPresenter'; +import type { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { + DeleteMediaResult, + DeleteMediaErrorCode, +} from '@core/media/application/use-cases/DeleteMediaUseCase'; import type { DeleteMediaOutputDTO } from '../dtos/DeleteMediaOutputDTO'; -type DeleteMediaOutput = DeleteMediaOutputDTO; +type DeleteMediaResponseModel = DeleteMediaOutputDTO; -export class DeleteMediaPresenter implements IDeleteMediaPresenter { - private result: DeleteMediaResult | null = null; +export type DeleteMediaApplicationError = ApplicationErrorCode< + DeleteMediaErrorCode, + { message: string } +>; - present(result: DeleteMediaResult) { - this.result = result; +export class DeleteMediaPresenter { + private model: DeleteMediaResponseModel | null = null; + + reset(): void { + this.model = null; } - get viewModel(): DeleteMediaOutput { - if (!this.result) throw new Error('Presenter not presented'); + present(result: Result): void { + if (result.isErr()) { + const error = result.unwrapErr(); - return { - success: this.result.success, - error: this.result.errorMessage, + this.model = { + success: false, + error: error.details?.message ?? 'Failed to delete media', + }; + return; + } + + const output = result.unwrap(); + + this.model = { + success: output.deleted, + error: undefined, }; } -} \ No newline at end of file + + getResponseModel(): DeleteMediaResponseModel | null { + return this.model; + } + + get responseModel(): DeleteMediaResponseModel { + if (!this.model) throw new Error('Presenter not presented'); + return this.model; + } +} diff --git a/apps/api/src/domain/media/presenters/GetAvatarPresenter.ts b/apps/api/src/domain/media/presenters/GetAvatarPresenter.ts index b08c867fb..2a60e74f4 100644 --- a/apps/api/src/domain/media/presenters/GetAvatarPresenter.ts +++ b/apps/api/src/domain/media/presenters/GetAvatarPresenter.ts @@ -1,22 +1,49 @@ -import type { IGetAvatarPresenter, GetAvatarResult } from '@core/media/application/presenters/IGetAvatarPresenter'; +import type { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { + GetAvatarResult, + GetAvatarErrorCode, +} from '@core/media/application/use-cases/GetAvatarUseCase'; import type { GetAvatarOutputDTO } from '../dtos/GetAvatarOutputDTO'; -export type GetAvatarViewModel = GetAvatarOutputDTO | null; +export type GetAvatarResponseModel = GetAvatarOutputDTO | null; -export class GetAvatarPresenter implements IGetAvatarPresenter { - private result: GetAvatarResult | null = null; +export type GetAvatarApplicationError = ApplicationErrorCode< + GetAvatarErrorCode, + { message: string } +>; - present(result: GetAvatarResult) { - this.result = result; +export class GetAvatarPresenter { + private model: GetAvatarResponseModel | null = null; + + reset(): void { + this.model = null; } - get viewModel(): GetAvatarViewModel { - if (!this.result || !this.result.success || !this.result.avatar) { - return null; + present(result: Result): void { + if (result.isErr()) { + const error = result.unwrapErr(); + + if (error.code === 'AVATAR_NOT_FOUND') { + this.model = null; + return; + } + + throw new Error(error.details?.message ?? 'Failed to get avatar'); } - return { - avatarUrl: this.result.avatar.mediaUrl, + const output = result.unwrap(); + + this.model = { + avatarUrl: output.avatar.mediaUrl, }; } + + getResponseModel(): GetAvatarResponseModel | null { + return this.model; + } + + get responseModel(): GetAvatarResponseModel { + return this.model ?? null; + } } \ 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 c21f70af4..3b9fab169 100644 --- a/apps/api/src/domain/media/presenters/GetMediaPresenter.ts +++ b/apps/api/src/domain/media/presenters/GetMediaPresenter.ts @@ -1,24 +1,39 @@ -import type { IGetMediaPresenter, GetMediaResult } from '@core/media/application/presenters/IGetMediaPresenter'; +import type { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { GetMediaResult, GetMediaErrorCode } from '@core/media/application/use-cases/GetMediaUseCase'; import type { GetMediaOutputDTO } from '../dtos/GetMediaOutputDTO'; -// The HTTP-facing DTO (or null when not found) -export type GetMediaViewModel = GetMediaOutputDTO | null; +export type GetMediaResponseModel = GetMediaOutputDTO | null; -export class GetMediaPresenter implements IGetMediaPresenter { - private result: GetMediaResult | null = null; +export type GetMediaApplicationError = ApplicationErrorCode< + GetMediaErrorCode, + { message: string } +>; - present(result: GetMediaResult) { - this.result = result; +export class GetMediaPresenter { + private model: GetMediaResponseModel | null = null; + + reset(): void { + this.model = null; } - get viewModel(): GetMediaViewModel { - if (!this.result || !this.result.success || !this.result.media) { - return null; + present(result: Result): void { + if (result.isErr()) { + const error = result.unwrapErr(); + + if (error.code === 'MEDIA_NOT_FOUND') { + this.model = null; + return; + } + + throw new Error(error.details?.message ?? 'Failed to get media'); } - const media = this.result.media; + const output = result.unwrap(); - return { + const media = output.media; + + this.model = { id: media.id, url: media.url, type: media.type, @@ -28,4 +43,12 @@ export class GetMediaPresenter implements IGetMediaPresenter { size: media.size, }; } + + getResponseModel(): GetMediaResponseModel | null { + return this.model; + } + + get responseModel(): GetMediaResponseModel { + return this.model ?? null; + } } \ No newline at end of file diff --git a/apps/api/src/domain/media/presenters/RequestAvatarGenerationPresenter.ts b/apps/api/src/domain/media/presenters/RequestAvatarGenerationPresenter.ts index e29b43686..eb1583ecd 100644 --- a/apps/api/src/domain/media/presenters/RequestAvatarGenerationPresenter.ts +++ b/apps/api/src/domain/media/presenters/RequestAvatarGenerationPresenter.ts @@ -1,30 +1,59 @@ +import type { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { + RequestAvatarGenerationResult, + RequestAvatarGenerationErrorCode, +} from '@core/media/application/use-cases/RequestAvatarGenerationUseCase'; import type { RequestAvatarGenerationOutputDTO } from '../dtos/RequestAvatarGenerationOutputDTO'; -import type { IRequestAvatarGenerationPresenter, RequestAvatarGenerationResultDTO } from '@core/media/application/presenters/IRequestAvatarGenerationPresenter'; -type RequestAvatarGenerationOutput = RequestAvatarGenerationOutputDTO; +type RequestAvatarGenerationResponseModel = RequestAvatarGenerationOutputDTO; -export class RequestAvatarGenerationPresenter implements IRequestAvatarGenerationPresenter { - private result: RequestAvatarGenerationOutput | null = null; +export type RequestAvatarGenerationApplicationError = ApplicationErrorCode< + RequestAvatarGenerationErrorCode, + { message: string } +>; + +export class RequestAvatarGenerationPresenter { + private model: RequestAvatarGenerationResponseModel | null = null; reset() { - this.result = null; + this.model = null; } - present(dto: RequestAvatarGenerationResultDTO) { - this.result = { - success: dto.status === 'completed', - requestId: dto.requestId, - avatarUrls: dto.avatarUrls, - errorMessage: dto.errorMessage, + present( + result: Result< + RequestAvatarGenerationResult, + RequestAvatarGenerationApplicationError + >, + ): void { + if (result.isErr()) { + const error = result.unwrapErr(); + + this.model = { + success: false, + requestId: '', + avatarUrls: [], + errorMessage: error.details?.message ?? 'Failed to request avatar generation', + }; + return; + } + + const output = result.unwrap(); + + this.model = { + success: output.status === 'completed', + requestId: output.requestId, + avatarUrls: output.avatarUrls, + errorMessage: undefined, }; } - get viewModel(): RequestAvatarGenerationOutput { - if (!this.result) throw new Error('Presenter not presented'); - return this.result; + getResponseModel(): RequestAvatarGenerationResponseModel | null { + return this.model; } - getViewModel(): RequestAvatarGenerationOutput { - return this.viewModel; + get responseModel(): RequestAvatarGenerationResponseModel { + if (!this.model) throw new Error('Presenter not presented'); + return this.model; } } \ 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 f98af42f7..3b93a728b 100644 --- a/apps/api/src/domain/media/presenters/UpdateAvatarPresenter.ts +++ b/apps/api/src/domain/media/presenters/UpdateAvatarPresenter.ts @@ -1,21 +1,45 @@ -import type { IUpdateAvatarPresenter, UpdateAvatarResult } from '@core/media/application/presenters/IUpdateAvatarPresenter'; +import type { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { + UpdateAvatarResult, + UpdateAvatarErrorCode, +} from '@core/media/application/use-cases/UpdateAvatarUseCase'; 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(): UpdateAvatarOutput { - if (!this.result) throw new Error('Presenter not presented'); +type UpdateAvatarResponseModel = UpdateAvatarOutputDTO; - return { - success: this.result.success, - error: this.result.errorMessage, +export type UpdateAvatarApplicationError = ApplicationErrorCode< + UpdateAvatarErrorCode, + { message: string } +>; + +export class UpdateAvatarPresenter { + private model: UpdateAvatarResponseModel | null = null; + + reset(): void { + this.model = null; + } + + present(result: Result): void { + if (result.isErr()) { + const error = result.unwrapErr(); + throw new Error(error.details?.message ?? 'Failed to update avatar'); + } + + const output = result.unwrap(); + + this.model = { + success: true, + error: undefined, }; } + + getResponseModel(): UpdateAvatarResponseModel | null { + return this.model; + } + + get responseModel(): UpdateAvatarResponseModel { + if (!this.model) throw new Error('Presenter not presented'); + return this.model; + } } \ 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 27487c836..93325e7b6 100644 --- a/apps/api/src/domain/media/presenters/UploadMediaPresenter.ts +++ b/apps/api/src/domain/media/presenters/UploadMediaPresenter.ts @@ -1,29 +1,52 @@ -import type { IUploadMediaPresenter, UploadMediaResult } from '@core/media/application/presenters/IUploadMediaPresenter'; +import type { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { + UploadMediaResult, + UploadMediaErrorCode, +} from '@core/media/application/use-cases/UploadMediaUseCase'; import type { UploadMediaOutputDTO } from '../dtos/UploadMediaOutputDTO'; -type UploadMediaOutput = UploadMediaOutputDTO; +type UploadMediaResponseModel = UploadMediaOutputDTO; -export class UploadMediaPresenter implements IUploadMediaPresenter { - private result: UploadMediaResult | null = null; +export type UploadMediaApplicationError = ApplicationErrorCode< + UploadMediaErrorCode, + { message: string } +>; - present(result: UploadMediaResult) { - this.result = result; +export class UploadMediaPresenter { + private model: UploadMediaResponseModel | null = null; + + reset(): void { + this.model = null; } - get viewModel(): UploadMediaOutput { - if (!this.result) throw new Error('Presenter not presented'); + present(result: Result): void { + if (result.isErr()) { + const error = result.unwrapErr(); - if (this.result.success) { - return { - success: true, - mediaId: this.result.mediaId, - url: this.result.url, + this.model = { + success: false, + error: error.details?.message ?? 'Upload failed', }; + return; } - return { - success: false, - error: this.result.errorMessage || 'Upload failed', + const output = result.unwrap(); + + this.model = { + success: true, + mediaId: output.mediaId, + url: output.url, + error: undefined, }; } -} \ No newline at end of file + + getResponseModel(): UploadMediaResponseModel | null { + return this.model; + } + + get responseModel(): UploadMediaResponseModel { + if (!this.model) throw new Error('Presenter not presented'); + return this.model; + } +} diff --git a/apps/api/src/domain/payments/presenters/CreatePaymentPresenter.ts b/apps/api/src/domain/payments/presenters/CreatePaymentPresenter.ts index f22a7e59b..87246ed03 100644 --- a/apps/api/src/domain/payments/presenters/CreatePaymentPresenter.ts +++ b/apps/api/src/domain/payments/presenters/CreatePaymentPresenter.ts @@ -5,22 +5,22 @@ import type { } from '@core/payments/application/presenters/ICreatePaymentPresenter'; export class CreatePaymentPresenter implements ICreatePaymentPresenter { - private result: CreatePaymentViewModel | null = null; + private responseModel: CreatePaymentViewModel | null = null; reset() { - this.result = null; + this.responseModel = null; } present(dto: CreatePaymentResultDTO) { - this.result = dto; + this.responseModel = dto; } - getViewModel(): CreatePaymentViewModel | null { - return this.result; + getResponseModel(): CreatePaymentViewModel | null { + return this.responseModel; } - get viewModel(): CreatePaymentViewModel { - if (!this.result) throw new Error('Presenter not presented'); - return this.result; + get responseModel(): CreatePaymentViewModel { + if (!this.responseModel) throw new Error('Presenter not presented'); + return this.responseModel; } } \ No newline at end of file diff --git a/apps/api/src/domain/protests/ProtestsController.test.ts b/apps/api/src/domain/protests/ProtestsController.test.ts index adb26b7c9..7d0e7759b 100644 --- a/apps/api/src/domain/protests/ProtestsController.test.ts +++ b/apps/api/src/domain/protests/ProtestsController.test.ts @@ -4,7 +4,7 @@ import { ForbiddenException, InternalServerErrorException, NotFoundException } f import { ProtestsController } from './ProtestsController'; import { ProtestsService } from './ProtestsService'; import { ReviewProtestCommandDTO } from '../race/dtos/ReviewProtestCommandDTO'; -import type { ReviewProtestPresenter } from './presenters/ReviewProtestPresenter'; +import type { ReviewProtestResponseDTO } from './presenters/ReviewProtestPresenter'; describe('ProtestsController', () => { let controller: ProtestsController; @@ -28,15 +28,7 @@ describe('ProtestsController', () => { 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); + const successDto = (dto: ReviewProtestResponseDTO): ReviewProtestResponseDTO => dto; describe('reviewProtest', () => { it('should call service and not throw on success', async () => { @@ -48,7 +40,7 @@ describe('ProtestsController', () => { }; reviewProtestMock.mockResolvedValue( - successPresenter({ + successDto({ success: true, protestId, stewardId: body.stewardId, @@ -70,7 +62,7 @@ describe('ProtestsController', () => { }; reviewProtestMock.mockResolvedValue( - successPresenter({ + successDto({ success: false, errorCode: 'PROTEST_NOT_FOUND', message: 'Protest not found', @@ -89,7 +81,7 @@ describe('ProtestsController', () => { }; reviewProtestMock.mockResolvedValue( - successPresenter({ + successDto({ success: false, errorCode: 'NOT_LEAGUE_ADMIN', message: 'Not authorized', @@ -108,7 +100,7 @@ describe('ProtestsController', () => { }; reviewProtestMock.mockResolvedValue( - successPresenter({ + successDto({ success: false, errorCode: 'UNEXPECTED_ERROR', message: 'Unexpected', diff --git a/apps/api/src/domain/protests/ProtestsController.ts b/apps/api/src/domain/protests/ProtestsController.ts index 3113af84d..77f7afd8c 100644 --- a/apps/api/src/domain/protests/ProtestsController.ts +++ b/apps/api/src/domain/protests/ProtestsController.ts @@ -2,6 +2,7 @@ import { Body, Controller, ForbiddenException, HttpCode, HttpStatus, InternalSer import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ProtestsService } from './ProtestsService'; import { ReviewProtestCommandDTO } from '../race/dtos/ReviewProtestCommandDTO'; +import type { ReviewProtestResponseDTO } from './presenters/ReviewProtestPresenter'; @ApiTags('protests') @Controller('protests') @@ -17,19 +18,18 @@ export class ProtestsController { @Param('protestId') protestId: string, @Body() body: Omit, ): Promise { - const presenter = await this.protestsService.reviewProtest({ protestId, ...body }); - const viewModel = presenter.viewModel; + const result: ReviewProtestResponseDTO = await this.protestsService.reviewProtest({ protestId, ...body }); - if (!viewModel.success) { - switch (viewModel.errorCode) { + if (!result.success) { + switch (result.errorCode) { case 'PROTEST_NOT_FOUND': - throw new NotFoundException(viewModel.message ?? 'Protest not found'); + throw new NotFoundException(result.message ?? 'Protest not found'); case 'RACE_NOT_FOUND': - throw new NotFoundException(viewModel.message ?? 'Race not found for protest'); + throw new NotFoundException(result.message ?? 'Race not found for protest'); case 'NOT_LEAGUE_ADMIN': - throw new ForbiddenException(viewModel.message ?? 'Steward is not authorized to review this protest'); + throw new ForbiddenException(result.message ?? 'Steward is not authorized to review this protest'); default: - throw new InternalServerErrorException(viewModel.message ?? 'Failed to review protest'); + throw new InternalServerErrorException(result.message ?? 'Failed to review protest'); } } } diff --git a/apps/api/src/domain/protests/ProtestsService.test.ts b/apps/api/src/domain/protests/ProtestsService.test.ts index b0a6c99a3..00fac6bbc 100644 --- a/apps/api/src/domain/protests/ProtestsService.test.ts +++ b/apps/api/src/domain/protests/ProtestsService.test.ts @@ -1,9 +1,13 @@ 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 type { + ReviewProtestUseCase, + ReviewProtestResult, + ReviewProtestApplicationError, +} from '@core/racing/application/use-cases/ReviewProtestUseCase'; import { ProtestsService } from './ProtestsService'; -import type { ReviewProtestPresenter } from './presenters/ReviewProtestPresenter'; +import type { ReviewProtestResponseDTO } from './presenters/ReviewProtestPresenter'; describe('ProtestsService', () => { let service: ProtestsService; @@ -30,16 +34,21 @@ describe('ProtestsService', () => { decisionNotes: 'Notes', }; - const getViewModel = (presenter: ReviewProtestPresenter) => presenter.viewModel; + it('returns DTO with success model on success', async () => { + const coreResult: ReviewProtestResult = { + leagueId: 'league-1', + protestId: baseCommand.protestId, + status: 'upheld', + stewardId: baseCommand.stewardId, + decision: baseCommand.decision, + }; - it('returns presenter with success view model on success', async () => { - executeMock.mockResolvedValue(Result.ok(undefined)); + executeMock.mockResolvedValue(Result.ok(coreResult)); - const presenter = await service.reviewProtest(baseCommand); - const viewModel = getViewModel(presenter as ReviewProtestPresenter); + const dto = await service.reviewProtest(baseCommand); expect(executeMock).toHaveBeenCalledWith(baseCommand); - expect(viewModel).toEqual({ + expect(dto).toEqual({ success: true, protestId: baseCommand.protestId, stewardId: baseCommand.stewardId, @@ -47,52 +56,69 @@ describe('ProtestsService', () => { }); }); - it('maps PROTEST_NOT_FOUND error into presenter', async () => { - executeMock.mockResolvedValue(Result.err({ code: 'PROTEST_NOT_FOUND' as const })); + it('maps PROTEST_NOT_FOUND error into DTO', async () => { + const error: ReviewProtestApplicationError = { + code: 'PROTEST_NOT_FOUND', + details: { message: 'Protest not found' }, + }; - const presenter = await service.reviewProtest(baseCommand); - const viewModel = getViewModel(presenter as ReviewProtestPresenter); + executeMock.mockResolvedValue(Result.err(error)); - expect(viewModel).toEqual({ + const dto = await service.reviewProtest(baseCommand); + + expect(dto).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 })); + it('maps RACE_NOT_FOUND error into DTO', async () => { + const error: ReviewProtestApplicationError = { + code: 'RACE_NOT_FOUND', + details: { message: 'Race not found for protest' }, + }; - const presenter = await service.reviewProtest(baseCommand); - const viewModel = getViewModel(presenter as ReviewProtestPresenter); + executeMock.mockResolvedValue(Result.err(error)); - expect(viewModel).toEqual({ + const dto = await service.reviewProtest(baseCommand); + + expect(dto).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 })); + it('maps NOT_LEAGUE_ADMIN error into DTO', async () => { + const error: ReviewProtestApplicationError = { + code: 'NOT_LEAGUE_ADMIN', + details: { message: 'Steward is not authorized to review this protest' }, + }; - const presenter = await service.reviewProtest(baseCommand); - const viewModel = getViewModel(presenter as ReviewProtestPresenter); + executeMock.mockResolvedValue(Result.err(error)); - expect(viewModel).toEqual({ + const dto = await service.reviewProtest(baseCommand); + + expect(dto).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 })); + it('maps unexpected error code into generic failure DTO', async () => { + const error: ReviewProtestApplicationError = { + // @ts-expect-error - simulate unexpected error code from core + code: 'UNEXPECTED', + details: { message: 'Failed to review protest' }, + }; - const presenter = await service.reviewProtest(baseCommand); - const viewModel = getViewModel(presenter as ReviewProtestPresenter); + executeMock.mockResolvedValue(Result.err(error)); - expect(viewModel).toEqual({ + const dto = await service.reviewProtest(baseCommand); + + expect(dto).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 d231b5f39..b28ad8db7 100644 --- a/apps/api/src/domain/protests/ProtestsService.ts +++ b/apps/api/src/domain/protests/ProtestsService.ts @@ -5,7 +5,7 @@ import type { Logger } from '@core/shared/application/Logger'; import { ReviewProtestUseCase } from '@core/racing/application/use-cases/ReviewProtestUseCase'; // Presenter -import { ReviewProtestPresenter } from './presenters/ReviewProtestPresenter'; +import { ReviewProtestPresenter, type ReviewProtestResponseDTO } from './presenters/ReviewProtestPresenter'; // Tokens import { LOGGER_TOKEN } from './ProtestsProviders'; @@ -22,41 +22,14 @@ 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); + const presenter = new ReviewProtestPresenter(); - if (result.isErr()) { - const error = result.unwrapErr(); + presenter.present(result); - 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; + return presenter.responseModel; } } diff --git a/apps/api/src/domain/protests/presenters/ReviewProtestPresenter.ts b/apps/api/src/domain/protests/presenters/ReviewProtestPresenter.ts index d11ca6fdb..5c237c9df 100644 --- a/apps/api/src/domain/protests/presenters/ReviewProtestPresenter.ts +++ b/apps/api/src/domain/protests/presenters/ReviewProtestPresenter.ts @@ -1,4 +1,10 @@ -export interface ReviewProtestViewModel { +import type { Result } from '@core/shared/application/Result'; +import type { + ReviewProtestResult, + ReviewProtestApplicationError, +} from '@core/racing/application/use-cases/ReviewProtestUseCase'; + +export interface ReviewProtestResponseDTO { success: boolean; errorCode?: string; message?: string; @@ -8,38 +14,45 @@ export interface ReviewProtestViewModel { } export class ReviewProtestPresenter { - private result: ReviewProtestViewModel | null = null; + private model: ReviewProtestResponseDTO | null = null; reset(): void { - this.result = null; + this.model = null; } - presentSuccess(payload: { protestId: string; stewardId: string; decision: 'uphold' | 'dismiss' }): void { - this.result = { + present( + result: Result, + ): void { + if (result.isErr()) { + const error = result.unwrapErr(); + + this.model = { + success: false, + errorCode: error.code, + message: error.details?.message, + }; + return; + } + + const value = result.unwrap(); + + this.model = { success: true, - protestId: payload.protestId, - stewardId: payload.stewardId, - decision: payload.decision, + protestId: value.protestId, + stewardId: value.stewardId, + decision: value.decision, }; } - presentError(errorCode: string, message?: string): void { - this.result = { - success: false, - errorCode, - message, - }; + getResponseModel(): ReviewProtestResponseDTO | null { + return this.model; } - getViewModel(): ReviewProtestViewModel | null { - return this.result; - } - - get viewModel(): ReviewProtestViewModel { - if (!this.result) { + get responseModel(): ReviewProtestResponseDTO { + if (!this.model) { throw new Error('Presenter not presented'); } - return this.result; + return this.model; } } diff --git a/apps/api/src/domain/race/RaceService.ts b/apps/api/src/domain/race/RaceService.ts index dea5a122f..3e298b14a 100644 --- a/apps/api/src/domain/race/RaceService.ts +++ b/apps/api/src/domain/race/RaceService.ts @@ -96,25 +96,20 @@ export class RaceService { 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 result = await this.getAllRacesUseCase.execute({}); + const presenter = new GetAllRacesPresenter(); - await presenter.present(result.unwrap()); + presenter.reset(); + presenter.present(result); + return presenter; } async getTotalRaces(): Promise { this.logger.debug('[RaceService] Fetching total races count.'); - const result = await this.getTotalRacesUseCase.execute(); - if (result.isErr()) { - throw new Error(result.unwrapErr().code); - } + const result = await this.getTotalRacesUseCase.execute({}); const presenter = new GetTotalRacesPresenter(); - presenter.present(result.unwrap()); + presenter.present(result); return presenter; } diff --git a/apps/api/src/domain/race/presenters/AllRacesPageDataPresenter.ts b/apps/api/src/domain/race/presenters/AllRacesPageDataPresenter.ts index 32fa7896a..d2ea19981 100644 --- a/apps/api/src/domain/race/presenters/AllRacesPageDataPresenter.ts +++ b/apps/api/src/domain/race/presenters/AllRacesPageDataPresenter.ts @@ -1,21 +1,50 @@ +import type { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { + GetAllRacesPageDataResult, + GetAllRacesPageDataErrorCode, +} from '@core/racing/application/use-cases/GetAllRacesPageDataUseCase'; import type { AllRacesPageDTO } from '../dtos/AllRacesPageDTO'; +export type AllRacesPageDataResponseModel = AllRacesPageDTO; + +export type GetAllRacesPageDataApplicationError = ApplicationErrorCode< + GetAllRacesPageDataErrorCode, + { message: string } +>; + export class AllRacesPageDataPresenter { - private result: AllRacesPageDTO | null = null; + private model: AllRacesPageDataResponseModel | null = null; - present(output: AllRacesPageDTO): void { - this.result = output; + reset(): void { + this.model = null; } - getViewModel(): AllRacesPageDTO | null { - return this.result; + present( + result: Result, + ): void { + if (result.isErr()) { + const error = result.unwrapErr(); + throw new Error(error.details?.message ?? 'Failed to get all races page data'); + } + + const output = result.unwrap(); + + this.model = { + races: output.races, + filters: output.filters, + }; } - get viewModel(): AllRacesPageDTO { - if (!this.result) { + getResponseModel(): AllRacesPageDataResponseModel | null { + return this.model; + } + + get responseModel(): AllRacesPageDataResponseModel { + if (!this.model) { throw new Error('Presenter not presented'); } - return this.result; + return this.model; } } diff --git a/apps/api/src/domain/race/presenters/CommandResultPresenter.ts b/apps/api/src/domain/race/presenters/CommandResultPresenter.ts index 0e6d5848c..94925dc22 100644 --- a/apps/api/src/domain/race/presenters/CommandResultPresenter.ts +++ b/apps/api/src/domain/race/presenters/CommandResultPresenter.ts @@ -1,36 +1,62 @@ -export interface CommandResultViewModel { +import type { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; + +export interface CommandResultDTO { success: boolean; errorCode?: string; message?: string; } -export class CommandResultPresenter { - private result: CommandResultViewModel | null = null; +export type CommandApplicationError = ApplicationErrorCode< + E, + { message: string } +>; + +export class CommandResultPresenter { + private model: CommandResultDTO | null = null; + + reset(): void { + this.model = null; + } + + present(result: Result>): void { + if (result.isErr()) { + const error = result.unwrapErr(); + this.model = { + success: false, + errorCode: error.code, + message: error.details?.message, + }; + return; + } + + this.model = { success: true }; + } presentSuccess(message?: string): void { - this.result = { + this.model = { success: true, message, }; } presentFailure(errorCode: string, message?: string): void { - this.result = { + this.model = { success: false, errorCode, message, }; } - getViewModel(): CommandResultViewModel | null { - return this.result; + getResponseModel(): CommandResultDTO | null { + return this.model; } - get viewModel(): CommandResultViewModel { - if (!this.result) { + get responseModel(): CommandResultDTO { + if (!this.model) { throw new Error('Presenter not presented'); } - return this.result; + return this.model; } } diff --git a/apps/api/src/domain/race/presenters/GetAllRacesPresenter.test.ts b/apps/api/src/domain/race/presenters/GetAllRacesPresenter.test.ts index 94cbb62fb..083fe9ac0 100644 --- a/apps/api/src/domain/race/presenters/GetAllRacesPresenter.test.ts +++ b/apps/api/src/domain/race/presenters/GetAllRacesPresenter.test.ts @@ -1,5 +1,6 @@ +import { Result } from '@core/shared/application/Result'; import { GetAllRacesPresenter } from './GetAllRacesPresenter'; -import type { GetAllRacesOutputPort } from '@core/racing/application/ports/output/GetAllRacesOutputPort'; +import type { GetAllRacesResult } from '@core/racing/application/use-cases/GetAllRacesUseCase'; describe('GetAllRacesPresenter', () => { it('should map races and distinct leagues into the DTO', async () => { diff --git a/apps/api/src/domain/race/presenters/GetAllRacesPresenter.ts b/apps/api/src/domain/race/presenters/GetAllRacesPresenter.ts index 1dfee75c5..b68eab575 100644 --- a/apps/api/src/domain/race/presenters/GetAllRacesPresenter.ts +++ b/apps/api/src/domain/race/presenters/GetAllRacesPresenter.ts @@ -1,33 +1,53 @@ -import { GetAllRacesOutputPort } from '@core/racing/application/ports/output/GetAllRacesOutputPort'; -import { AllRacesPageDTO } from '../dtos/AllRacesPageDTO'; +import type { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { + GetAllRacesResult, + GetAllRacesErrorCode, +} from '@core/racing/application/use-cases/GetAllRacesUseCase'; +import type { AllRacesPageDTO } from '../dtos/AllRacesPageDTO'; + +export type GetAllRacesResponseModel = AllRacesPageDTO; + +export type GetAllRacesApplicationError = ApplicationErrorCode< + GetAllRacesErrorCode, + { message: string } +>; export class GetAllRacesPresenter { - private result: AllRacesPageDTO | null = null; + private model: GetAllRacesResponseModel | null = null; - reset() { - this.result = null; + reset(): void { + this.model = null; } - async present(output: GetAllRacesOutputPort) { - const uniqueLeagues = new Map(); - - for (const race of output.races) { - uniqueLeagues.set(race.leagueId, { - id: race.leagueId, - name: race.leagueName, - }); + present(result: Result): void { + if (result.isErr()) { + const error = result.unwrapErr(); + throw new Error(error.details?.message ?? 'Failed to get all races'); } - this.result = { + const output = result.unwrap(); + + const leagueMap = new Map(); + const uniqueLeagues = new Map(); + + for (const league of output.leagues) { + const id = league.id.toString(); + const name = league.name.toString(); + leagueMap.set(id, name); + uniqueLeagues.set(id, { id, name }); + } + + this.model = { races: output.races.map(race => ({ id: race.id, track: race.track, car: race.car, - scheduledAt: race.scheduledAt, + scheduledAt: race.scheduledAt.toISOString(), status: race.status, leagueId: race.leagueId, - leagueName: race.leagueName, - strengthOfField: race.strengthOfField, + leagueName: leagueMap.get(race.leagueId) ?? 'Unknown League', + strengthOfField: race.strengthOfField ?? null, })), filters: { statuses: [ @@ -42,7 +62,15 @@ export class GetAllRacesPresenter { }; } - getViewModel(): AllRacesPageDTO | null { - return this.result; + getResponseModel(): GetAllRacesResponseModel | null { + return this.model; + } + + get responseModel(): GetAllRacesResponseModel { + if (!this.model) { + throw new Error('Presenter not presented'); + } + + return this.model; } } \ No newline at end of file diff --git a/apps/api/src/domain/race/presenters/GetTotalRacesPresenter.ts b/apps/api/src/domain/race/presenters/GetTotalRacesPresenter.ts index 14eefe99e..c889f4e88 100644 --- a/apps/api/src/domain/race/presenters/GetTotalRacesPresenter.ts +++ b/apps/api/src/domain/race/presenters/GetTotalRacesPresenter.ts @@ -1,20 +1,47 @@ -import { GetTotalRacesOutputPort } from '@core/racing/application/ports/output/GetTotalRacesOutputPort'; -import { RaceStatsDTO } from '../dtos/RaceStatsDTO'; +import type { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { + GetTotalRacesResult, + GetTotalRacesErrorCode, +} from '@core/racing/application/use-cases/GetTotalRacesUseCase'; +import type { RaceStatsDTO } from '../dtos/RaceStatsDTO'; + +export type GetTotalRacesResponseModel = RaceStatsDTO; + +export type GetTotalRacesApplicationError = ApplicationErrorCode< + GetTotalRacesErrorCode, + { message: string } +>; export class GetTotalRacesPresenter { - private result: RaceStatsDTO | null = null; + private model: GetTotalRacesResponseModel | null = null; - reset() { - this.result = null; + reset(): void { + this.model = null; } - present(output: GetTotalRacesOutputPort) { - this.result = { + present(result: Result): void { + if (result.isErr()) { + const error = result.unwrapErr(); + throw new Error(error.details?.message ?? 'Failed to get total races'); + } + + const output = result.unwrap(); + + this.model = { totalRaces: output.totalRaces, }; } - getViewModel(): RaceStatsDTO | null { - return this.result; + getResponseModel(): GetTotalRacesResponseModel | null { + return this.model; + } + + get responseModel(): GetTotalRacesResponseModel { + if (!this.model) { + throw new Error('Presenter not presented'); + } + + return this.model; } } \ No newline at end of file diff --git a/apps/api/src/domain/race/presenters/ImportRaceResultsApiPresenter.ts b/apps/api/src/domain/race/presenters/ImportRaceResultsApiPresenter.ts index d93fc22d5..81a7a565a 100644 --- a/apps/api/src/domain/race/presenters/ImportRaceResultsApiPresenter.ts +++ b/apps/api/src/domain/race/presenters/ImportRaceResultsApiPresenter.ts @@ -1,15 +1,36 @@ -import { ImportRaceResultsApiOutputPort } from '@core/racing/application/ports/output/ImportRaceResultsApiOutputPort'; +import type { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { + ImportRaceResultsApiResult, + ImportRaceResultsApiErrorCode, +} from '@core/racing/application/use-cases/ImportRaceResultsApiUseCase'; import { ImportRaceResultsSummaryDTO } from '../dtos/ImportRaceResultsSummaryDTO'; -export class ImportRaceResultsApiPresenter { - private result: ImportRaceResultsSummaryDTO | null = null; +export type ImportRaceResultsApiResponseModel = ImportRaceResultsSummaryDTO; - reset() { - this.result = null; +export type ImportRaceResultsApiApplicationError = ApplicationErrorCode< + ImportRaceResultsApiErrorCode, + { message: string } +>; + +export class ImportRaceResultsApiPresenter { + private model: ImportRaceResultsApiResponseModel | null = null; + + reset(): void { + this.model = null; } - present(output: ImportRaceResultsApiOutputPort) { - this.result = { + present( + result: Result, + ): void { + if (result.isErr()) { + const error = result.unwrapErr(); + throw new Error(error.details?.message ?? 'Failed to import race results'); + } + + const output = result.unwrap(); + + this.model = { success: output.success, raceId: output.raceId, driversProcessed: output.driversProcessed, @@ -18,7 +39,15 @@ export class ImportRaceResultsApiPresenter { }; } - getViewModel(): ImportRaceResultsSummaryDTO | null { - return this.result; + getResponseModel(): ImportRaceResultsApiResponseModel | null { + return this.model; + } + + get responseModel(): ImportRaceResultsApiResponseModel { + if (!this.model) { + throw new Error('Presenter not presented'); + } + + return this.model; } } \ No newline at end of file diff --git a/apps/api/src/domain/race/presenters/RaceDetailPresenter.ts b/apps/api/src/domain/race/presenters/RaceDetailPresenter.ts index 302405228..9b7e0c5e2 100644 --- a/apps/api/src/domain/race/presenters/RaceDetailPresenter.ts +++ b/apps/api/src/domain/race/presenters/RaceDetailPresenter.ts @@ -1,4 +1,9 @@ -import type { RaceDetailOutputPort } from '@core/racing/application/ports/output/RaceDetailOutputPort'; +import type { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { + GetRaceDetailResult, + GetRaceDetailErrorCode, +} from '@core/racing/application/use-cases/GetRaceDetailUseCase'; import type { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider'; import type { IImageServicePort } from '@core/racing/application/ports/IImageServicePort'; import type { GetRaceDetailParamsDTO } from '../dtos/GetRaceDetailParamsDTO'; @@ -9,44 +14,79 @@ import type { RaceDetailEntryDTO } from '../dtos/RaceDetailEntryDTO'; import type { RaceDetailRegistrationDTO } from '../dtos/RaceDetailRegistrationDTO'; import type { RaceDetailUserResultDTO } from '../dtos/RaceDetailUserResultDTO'; +export type GetRaceDetailResponseModel = RaceDetailDTO; + +export type GetRaceDetailApplicationError = ApplicationErrorCode< + GetRaceDetailErrorCode, + { message: string } +>; + export class RaceDetailPresenter { - private result: RaceDetailDTO | null = null; + private model: GetRaceDetailResponseModel | null = null; constructor( private readonly driverRatingProvider: DriverRatingProvider, private readonly imageService: IImageServicePort, ) {} - async present(outputPort: RaceDetailOutputPort, params: GetRaceDetailParamsDTO): Promise { - const raceDTO: RaceDetailRaceDTO | null = outputPort.race + reset(): void { + this.model = null; + } + + async present( + result: Result, + params: GetRaceDetailParamsDTO, + ): Promise { + if (result.isErr()) { + const error = result.unwrapErr(); + if (error.code === 'RACE_NOT_FOUND') { + this.model = { + race: null, + league: null, + entryList: [], + registration: { + isUserRegistered: false, + canRegister: false, + }, + userResult: null, + } as RaceDetailDTO; + return; + } + + throw new Error(error.details?.message ?? 'Failed to get race detail'); + } + + const output = result.unwrap(); + + const raceDTO: RaceDetailRaceDTO | null = output.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, + id: output.race.id, + leagueId: output.race.leagueId, + track: output.race.track, + car: output.race.car, + scheduledAt: output.race.scheduledAt.toISOString(), + sessionType: output.race.sessionType, + status: output.race.status, + strengthOfField: output.race.strengthOfField ?? null, + registeredCount: output.race.registeredCount ?? undefined, + maxParticipants: output.race.maxParticipants ?? undefined, } : null; - const leagueDTO: RaceDetailLeagueDTO | null = outputPort.league + const leagueDTO: RaceDetailLeagueDTO | null = output.league ? { - id: outputPort.league.id.toString(), - name: outputPort.league.name.toString(), - description: outputPort.league.description.toString(), + id: output.league.id.toString(), + name: output.league.name.toString(), + description: output.league.description.toString(), settings: { - maxDrivers: outputPort.league.settings.maxDrivers ?? undefined, - qualifyingFormat: outputPort.league.settings.qualifyingFormat ?? undefined, + maxDrivers: output.league.settings.maxDrivers ?? undefined, + qualifyingFormat: output.league.settings.qualifyingFormat ?? undefined, }, } : null; const entryListDTO: RaceDetailEntryDTO[] = await Promise.all( - outputPort.drivers.map(async driver => { + output.drivers.map(async driver => { const ratingResult = await this.driverRatingProvider.getDriverRating({ driverId: driver.id }); const avatarResult = await this.imageService.getDriverAvatar({ driverId: driver.id }); return { @@ -61,24 +101,24 @@ export class RaceDetailPresenter { ); const registrationDTO: RaceDetailRegistrationDTO = { - isUserRegistered: outputPort.isUserRegistered, - canRegister: outputPort.canRegister, + isUserRegistered: output.isUserRegistered, + canRegister: output.canRegister, }; - const userResultDTO: RaceDetailUserResultDTO | null = outputPort.userResult + const userResultDTO: RaceDetailUserResultDTO | null = output.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()), + position: output.userResult.position.toNumber(), + startPosition: output.userResult.startPosition.toNumber(), + incidents: output.userResult.incidents.toNumber(), + fastestLap: output.userResult.fastestLap.toNumber(), + positionChange: output.userResult.getPositionChange(), + isPodium: output.userResult.isPodium(), + isClean: output.userResult.isClean(), + ratingChange: this.calculateRatingChange(output.userResult.position.toNumber()), } : null; - this.result = { + this.model = { race: raceDTO, league: leagueDTO, entryList: entryListDTO, @@ -87,16 +127,16 @@ export class RaceDetailPresenter { } as RaceDetailDTO; } - getViewModel(): RaceDetailDTO | null { - return this.result; + getResponseModel(): GetRaceDetailResponseModel | null { + return this.model; } - get viewModel(): RaceDetailDTO { - if (!this.result) { + get responseModel(): GetRaceDetailResponseModel { + if (!this.model) { throw new Error('Presenter not presented'); } - return this.result; + return this.model; } private calculateRatingChange(position: number): number { diff --git a/apps/api/src/domain/race/presenters/RacePenaltiesPresenter.ts b/apps/api/src/domain/race/presenters/RacePenaltiesPresenter.ts index 3cdac78c6..4d1861a87 100644 --- a/apps/api/src/domain/race/presenters/RacePenaltiesPresenter.ts +++ b/apps/api/src/domain/race/presenters/RacePenaltiesPresenter.ts @@ -1,12 +1,35 @@ -import type { RacePenaltiesOutputPort } from '@core/racing/application/ports/output/RacePenaltiesOutputPort'; +import type { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { + GetRacePenaltiesResult, + GetRacePenaltiesErrorCode, +} from '@core/racing/application/use-cases/GetRacePenaltiesUseCase'; import type { RacePenaltiesDTO } from '../dtos/RacePenaltiesDTO'; import type { RacePenaltyDTO } from '../dtos/RacePenaltyDTO'; -export class RacePenaltiesPresenter { - private result: RacePenaltiesDTO | null = null; +export type GetRacePenaltiesResponseModel = RacePenaltiesDTO; - present(outputPort: RacePenaltiesOutputPort): void { - const penalties: RacePenaltyDTO[] = outputPort.penalties.map(penalty => ({ +export type GetRacePenaltiesApplicationError = ApplicationErrorCode< + GetRacePenaltiesErrorCode, + { message: string } +>; + +export class RacePenaltiesPresenter { + private model: GetRacePenaltiesResponseModel | null = null; + + reset(): void { + this.model = null; + } + + present(result: Result): void { + if (result.isErr()) { + const error = result.unwrapErr(); + throw new Error(error.details?.message ?? 'Failed to get race penalties'); + } + + const output = result.unwrap(); + + const penalties: RacePenaltyDTO[] = output.penalties.map(penalty => ({ id: penalty.id, driverId: penalty.driverId, type: penalty.type, @@ -18,25 +41,25 @@ export class RacePenaltiesPresenter { } as RacePenaltyDTO)); const driverMap: Record = {}; - outputPort.drivers.forEach(driver => { + output.drivers.forEach(driver => { driverMap[driver.id] = driver.name.toString(); }); - this.result = { + this.model = { penalties, driverMap, } as RacePenaltiesDTO; } - getViewModel(): RacePenaltiesDTO | null { - return this.result; + getResponseModel(): GetRacePenaltiesResponseModel | null { + return this.model; } - get viewModel(): RacePenaltiesDTO { - if (!this.result) { + get responseModel(): GetRacePenaltiesResponseModel { + if (!this.model) { throw new Error('Presenter not presented'); } - return this.result; + return this.model; } } diff --git a/apps/api/src/domain/race/presenters/RaceProtestsPresenter.ts b/apps/api/src/domain/race/presenters/RaceProtestsPresenter.ts index abdc1d509..67808d6f2 100644 --- a/apps/api/src/domain/race/presenters/RaceProtestsPresenter.ts +++ b/apps/api/src/domain/race/presenters/RaceProtestsPresenter.ts @@ -1,12 +1,35 @@ -import type { RaceProtestsOutputPort } from '@core/racing/application/ports/output/RaceProtestsOutputPort'; +import type { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { + GetRaceProtestsResult, + GetRaceProtestsErrorCode, +} from '@core/racing/application/use-cases/GetRaceProtestsUseCase'; import type { RaceProtestsDTO } from '../dtos/RaceProtestsDTO'; import type { RaceProtestDTO } from '../dtos/RaceProtestDTO'; -export class RaceProtestsPresenter { - private result: RaceProtestsDTO | null = null; +export type GetRaceProtestsResponseModel = RaceProtestsDTO; - present(outputPort: RaceProtestsOutputPort): void { - const protests: RaceProtestDTO[] = outputPort.protests.map(protest => ({ +export type GetRaceProtestsApplicationError = ApplicationErrorCode< + GetRaceProtestsErrorCode, + { message: string } +>; + +export class RaceProtestsPresenter { + private model: GetRaceProtestsResponseModel | null = null; + + reset(): void { + this.model = null; + } + + present(result: Result): void { + if (result.isErr()) { + const error = result.unwrapErr(); + throw new Error(error.details?.message ?? 'Failed to get race protests'); + } + + const output = result.unwrap(); + + const protests: RaceProtestDTO[] = output.protests.map(protest => ({ id: protest.id, protestingDriverId: protest.protestingDriverId, accusedDriverId: protest.accusedDriverId, @@ -19,25 +42,25 @@ export class RaceProtestsPresenter { } as RaceProtestDTO)); const driverMap: Record = {}; - outputPort.drivers.forEach(driver => { + output.drivers.forEach(driver => { driverMap[driver.id] = driver.name.toString(); }); - this.result = { + this.model = { protests, driverMap, } as RaceProtestsDTO; } - getViewModel(): RaceProtestsDTO | null { - return this.result; + getResponseModel(): GetRaceProtestsResponseModel | null { + return this.model; } - get viewModel(): RaceProtestsDTO { - if (!this.result) { + get responseModel(): GetRaceProtestsResponseModel { + if (!this.model) { throw new Error('Presenter not presented'); } - return this.result; + return this.model; } } diff --git a/apps/api/src/domain/race/presenters/RaceResultsDetailPresenter.ts b/apps/api/src/domain/race/presenters/RaceResultsDetailPresenter.ts index 02d2ebfae..691280515 100644 --- a/apps/api/src/domain/race/presenters/RaceResultsDetailPresenter.ts +++ b/apps/api/src/domain/race/presenters/RaceResultsDetailPresenter.ts @@ -1,18 +1,53 @@ -import type { RaceResultsDetailOutputPort } from '@core/racing/application/ports/output/RaceResultsDetailOutputPort'; +import type { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { + GetRaceResultsDetailResult, + GetRaceResultsDetailErrorCode, +} from '@core/racing/application/use-cases/GetRaceResultsDetailUseCase'; import type { IImageServicePort } from '@core/racing/application/ports/IImageServicePort'; import type { RaceResultsDetailDTO } from '../dtos/RaceResultsDetailDTO'; import type { RaceResultDTO } from '../dtos/RaceResultDTO'; +export type GetRaceResultsDetailResponseModel = RaceResultsDetailDTO; + +export type GetRaceResultsDetailApplicationError = ApplicationErrorCode< + GetRaceResultsDetailErrorCode, + { message: string } +>; + export class RaceResultsDetailPresenter { - private result: RaceResultsDetailDTO | null = null; + private model: GetRaceResultsDetailResponseModel | null = null; constructor(private readonly imageService: IImageServicePort) {} - async present(outputPort: RaceResultsDetailOutputPort): Promise { - const driverMap = new Map(outputPort.drivers.map(driver => [driver.id, driver])); + reset(): void { + this.model = null; + } + + async present( + result: Result, + ): Promise { + if (result.isErr()) { + const error = result.unwrapErr(); + + if (error.code === 'RACE_NOT_FOUND') { + this.model = { + raceId: '', + track: '', + results: [], + } as RaceResultsDetailDTO; + return; + } + + throw new Error(error.details?.message ?? 'Failed to get race results detail'); + } + + const output = result.unwrap(); + + const driverMap = new Map(output.drivers.map(driver => [driver.id, driver])); const results: RaceResultDTO[] = await Promise.all( - outputPort.results.map(async singleResult => { + output.results.map(async singleResult => { const driver = driverMap.get(singleResult.driverId.toString()); if (!driver) { throw new Error(`Driver not found for result: ${singleResult.driverId}`); @@ -35,22 +70,22 @@ export class RaceResultsDetailPresenter { }), ); - this.result = { - raceId: outputPort.race.id, - track: outputPort.race.track, + this.model = { + raceId: output.race.id, + track: output.race.track, results, } as RaceResultsDetailDTO; } - getViewModel(): RaceResultsDetailDTO | null { - return this.result; + getResponseModel(): GetRaceResultsDetailResponseModel | null { + return this.model; } - get viewModel(): RaceResultsDetailDTO { - if (!this.result) { + get responseModel(): GetRaceResultsDetailResponseModel { + if (!this.model) { throw new Error('Presenter not presented'); } - return this.result; + return this.model; } } diff --git a/apps/api/src/domain/race/presenters/RaceWithSOFPresenter.ts b/apps/api/src/domain/race/presenters/RaceWithSOFPresenter.ts index 010e56e6f..a9021a278 100644 --- a/apps/api/src/domain/race/presenters/RaceWithSOFPresenter.ts +++ b/apps/api/src/domain/race/presenters/RaceWithSOFPresenter.ts @@ -1,26 +1,58 @@ -import type { RaceWithSOFOutputPort } from '@core/racing/application/ports/output/RaceWithSOFOutputPort'; +import type { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { + GetRaceWithSOFResult, + GetRaceWithSOFErrorCode, +} from '@core/racing/application/use-cases/GetRaceWithSOFUseCase'; import type { RaceWithSOFDTO } from '../dtos/RaceWithSOFDTO'; -export class RaceWithSOFPresenter { - private result: RaceWithSOFDTO | null = null; +export type GetRaceWithSOFResponseModel = RaceWithSOFDTO; - present(outputPort: RaceWithSOFOutputPort): void { - this.result = { - id: outputPort.id, - track: outputPort.track, - strengthOfField: outputPort.strengthOfField, +export type GetRaceWithSOFApplicationError = ApplicationErrorCode< + GetRaceWithSOFErrorCode, + { message: string } +>; + +export class RaceWithSOFPresenter { + private model: GetRaceWithSOFResponseModel | null = null; + + reset(): void { + this.model = null; + } + + present(result: Result): void { + if (result.isErr()) { + const error = result.unwrapErr(); + if (error.code === 'RACE_NOT_FOUND') { + this.model = { + id: '', + track: '', + strengthOfField: null, + } as RaceWithSOFDTO; + return; + } + + throw new Error(error.details?.message ?? 'Failed to get race with SOF'); + } + + const output = result.unwrap(); + + this.model = { + id: output.race.id, + track: output.race.track, + strengthOfField: output.strengthOfField, } as RaceWithSOFDTO; } - getViewModel(): RaceWithSOFDTO | null { - return this.result; + getResponseModel(): GetRaceWithSOFResponseModel | null { + return this.model; } - get viewModel(): RaceWithSOFDTO { - if (!this.result) { + get responseModel(): GetRaceWithSOFResponseModel { + if (!this.model) { throw new Error('Presenter not presented'); } - return this.result; + return this.model; } } diff --git a/apps/api/src/domain/race/presenters/RacesPageDataPresenter.ts b/apps/api/src/domain/race/presenters/RacesPageDataPresenter.ts index b918f0a8b..02fd790e9 100644 --- a/apps/api/src/domain/race/presenters/RacesPageDataPresenter.ts +++ b/apps/api/src/domain/race/presenters/RacesPageDataPresenter.ts @@ -1,43 +1,62 @@ -import type { RacesPageOutputPort } from '@core/racing/application/ports/output/RacesPageOutputPort'; -import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository'; +import type { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { + GetRacesPageDataResult, + GetRacesPageDataErrorCode, +} from '@core/racing/application/use-cases/GetRacesPageDataUseCase'; import type { RacesPageDataDTO } from '../dtos/RacesPageDataDTO'; import type { RacesPageDataRaceDTO } from '../dtos/RacesPageDataRaceDTO'; +export type GetRacesPageDataResponseModel = RacesPageDataDTO; + +export type GetRacesPageDataApplicationError = ApplicationErrorCode< + GetRacesPageDataErrorCode, + { message: string } +>; + export class RacesPageDataPresenter { - private result: RacesPageDataDTO | null = null; + private model: GetRacesPageDataResponseModel | null = null; - constructor(private readonly leagueRepository: ILeagueRepository) {} + reset(): void { + this.model = null; + } - async present(outputPort: RacesPageOutputPort): Promise { - const allLeagues = await this.leagueRepository.findAll(); - const leagueMap = new Map(allLeagues.map(l => [l.id, l.name])); + present( + result: Result, + ): void { + if (result.isErr()) { + const error = result.unwrapErr(); + throw new Error(error.details?.message ?? 'Failed to get races page data'); + } - const races: RacesPageDataRaceDTO[] = outputPort.races.map(race => ({ + const output = result.unwrap(); + + const races: RacesPageDataRaceDTO[] = output.races.map(({ race, leagueName }) => ({ 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, + leagueName, + strengthOfField: race.strengthOfField ?? null, isUpcoming: race.scheduledAt > new Date(), isLive: race.status === 'running', isPast: race.scheduledAt < new Date() && race.status === 'completed', })); - this.result = { races } as RacesPageDataDTO; + this.model = { races } as RacesPageDataDTO; } - getViewModel(): RacesPageDataDTO | null { - return this.result; + getResponseModel(): GetRacesPageDataResponseModel | null { + return this.model; } - get viewModel(): RacesPageDataDTO { - if (!this.result) { + get responseModel(): GetRacesPageDataResponseModel { + if (!this.model) { throw new Error('Presenter not presented'); } - return this.result; + return this.model; } } diff --git a/apps/api/src/domain/sponsor/SponsorProviders.ts b/apps/api/src/domain/sponsor/SponsorProviders.ts index 7db5c7d91..8016c59a8 100644 --- a/apps/api/src/domain/sponsor/SponsorProviders.ts +++ b/apps/api/src/domain/sponsor/SponsorProviders.ts @@ -17,8 +17,7 @@ import { ISponsorshipPricingRepository } from '@core/racing/domain/repositories/ import { ISponsorshipRequestRepository } from '@core/racing/domain/repositories/ISponsorshipRequestRepository'; import type { Logger } from '@core/shared/application'; -// Import use cases / application services -import { SponsorBillingService } from '@core/payments/application/services/SponsorBillingService'; +import { GetSponsorBillingUseCase } from '@core/payments/application/use-cases/GetSponsorBillingUseCase'; import { AcceptSponsorshipRequestUseCase } from '@core/racing/application/use-cases/AcceptSponsorshipRequestUseCase'; import { CreateSponsorUseCase } from '@core/racing/application/use-cases/CreateSponsorUseCase'; import { GetEntitySponsorshipPricingUseCase } from '@core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase'; @@ -63,7 +62,7 @@ export const GET_SPONSOR_USE_CASE_TOKEN = 'GetSponsorUseCase'; export const GET_PENDING_SPONSORSHIP_REQUESTS_USE_CASE_TOKEN = 'GetPendingSponsorshipRequestsUseCase'; export const ACCEPT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN = 'AcceptSponsorshipRequestUseCase'; export const REJECT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN = 'RejectSponsorshipRequestUseCase'; -export const SPONSOR_BILLING_SERVICE_TOKEN = 'SponsorBillingService'; +export const GET_SPONSOR_BILLING_USE_CASE_TOKEN = 'GetSponsorBillingUseCase'; export const SponsorProviders: Provider[] = [ SponsorService, @@ -154,9 +153,9 @@ export const SponsorProviders: Provider[] = [ ], }, { - provide: SPONSOR_BILLING_SERVICE_TOKEN, + provide: GET_SPONSOR_BILLING_USE_CASE_TOKEN, useFactory: (paymentRepo: IPaymentRepository, seasonSponsorshipRepo: ISeasonSponsorshipRepository) => - new SponsorBillingService(paymentRepo, seasonSponsorshipRepo), + new GetSponsorBillingUseCase(paymentRepo, seasonSponsorshipRepo), inject: ['IPaymentRepository', SEASON_SPONSORSHIP_REPOSITORY_TOKEN], }, { diff --git a/apps/api/src/domain/sponsor/SponsorService.ts b/apps/api/src/domain/sponsor/SponsorService.ts index d59273fbe..12f6c8528 100644 --- a/apps/api/src/domain/sponsor/SponsorService.ts +++ b/apps/api/src/domain/sponsor/SponsorService.ts @@ -28,6 +28,8 @@ import { } 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 { GetSponsorBillingUseCase } from '@core/payments/application/use-cases/GetSponsorBillingUseCase'; +import { GET_SPONSOR_BILLING_USE_CASE_TOKEN } from './SponsorProviders'; import type { SponsorableEntityType } from '@core/racing/domain/entities/SponsorshipRequest'; import type { Logger } from '@core/shared/application'; @@ -82,6 +84,8 @@ export class SponsorService { private readonly acceptSponsorshipRequestUseCase: AcceptSponsorshipRequestUseCase, @Inject(REJECT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN) private readonly rejectSponsorshipRequestUseCase: RejectSponsorshipRequestUseCase, + @Inject(GET_SPONSOR_BILLING_USE_CASE_TOKEN) + private readonly getSponsorBillingUseCase: GetSponsorBillingUseCase, @Inject(LOGGER_TOKEN) private readonly logger: Logger, ) {} @@ -102,20 +106,15 @@ export class SponsorService { 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); - presenter.present({ sponsors: [] }); - return presenter; - } + presenter.present(result); - presenter.present(result.value); - return presenter; + return presenter.responseModel; } async createSponsor(input: CreateSponsorInputDTO): Promise { @@ -264,92 +263,18 @@ export class SponsorService { return presenter; } - async getSponsorBilling(sponsorId: string): Promise { + async getSponsorBilling(sponsorId: string): Promise { this.logger.debug('[SponsorService] Fetching sponsor billing.', { sponsorId }); - const presenter = new SponsorBillingPresenter(); + const result = await this.getSponsorBillingUseCase.execute({ sponsorId }); - // Mock data - in real implementation, this would come from repositories - const paymentMethods: PaymentMethodDTO[] = [ - { - id: 'pm-1', - type: 'card', - last4: '4242', - brand: 'Visa', - isDefault: true, - expiryMonth: 12, - expiryYear: 2027, - }, - { - id: 'pm-2', - type: 'card', - last4: '5555', - brand: 'Mastercard', - isDefault: false, - expiryMonth: 6, - expiryYear: 2026, - }, - { - id: 'pm-3', - type: 'sepa', - last4: '8901', - bankName: 'Deutsche Bank', - isDefault: false, - }, - ]; + if (result.isErr()) { + this.logger.error('[SponsorService] Failed to fetch sponsor billing.', result.error); + throw new Error(result.error.details?.message || 'Failed to fetch sponsor billing'); + } - const invoices: InvoiceDTO[] = [ - { - id: 'inv-1', - invoiceNumber: 'GP-2025-001234', - date: '2025-11-01', - dueDate: '2025-11-15', - amount: 1090.91, - vatAmount: 207.27, - totalAmount: 1298.18, - status: 'paid', - description: 'GT3 Pro Championship - Primary Sponsor (Q4 2025)', - sponsorshipType: 'league', - pdfUrl: '#', - }, - { - id: 'inv-2', - invoiceNumber: 'GP-2025-001235', - date: '2025-10-01', - dueDate: '2025-10-15', - amount: 363.64, - vatAmount: 69.09, - totalAmount: 432.73, - status: 'paid', - description: 'Team Velocity - Gear Sponsor (Q4 2025)', - sponsorshipType: 'team', - pdfUrl: '#', - }, - { - id: 'inv-3', - invoiceNumber: 'GP-2025-001236', - date: '2025-12-01', - dueDate: '2025-12-15', - amount: 318.18, - vatAmount: 60.45, - totalAmount: 378.63, - status: 'pending', - description: 'Alex Thompson - Driver Sponsorship (Dec 2025)', - sponsorshipType: 'driver', - pdfUrl: '#', - }, - ]; - - const stats: BillingStatsDTO = { - totalSpent: 12450, - pendingAmount: 919.54, - nextPaymentDate: '2025-12-15', - nextPaymentAmount: 378.63, - activeSponsorships: 6, - averageMonthlySpend: 2075, - }; - - presenter.present({ paymentMethods, invoices, stats }); + const presenter = new GetSponsorBillingPresenter(); + presenter.present(result.value); return presenter; } diff --git a/apps/api/src/domain/sponsor/presenters/GetSponsorsPresenter.test.ts b/apps/api/src/domain/sponsor/presenters/GetSponsorsPresenter.test.ts index f6972da0f..62ef8322a 100644 --- a/apps/api/src/domain/sponsor/presenters/GetSponsorsPresenter.test.ts +++ b/apps/api/src/domain/sponsor/presenters/GetSponsorsPresenter.test.ts @@ -1,4 +1,10 @@ import { describe, it, expect, beforeEach } from 'vitest'; +import { Result } from '@core/shared/application/Result'; +import type { + GetSponsorsResult, + GetSponsorsErrorCode, +} from '@core/racing/application/use-cases/GetSponsorsUseCase'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import { GetSponsorsPresenter } from './GetSponsorsPresenter'; describe('GetSponsorsPresenter', () => { @@ -9,54 +15,92 @@ describe('GetSponsorsPresenter', () => { }); describe('reset', () => { - it('should reset the result to null', () => { - const mockResult = { sponsors: [] }; - presenter.present(mockResult); - expect(presenter.viewModel).toEqual(mockResult); + it('should reset the model to null and cause responseModel to throw', () => { + const result = Result.ok({ sponsors: [] }); + presenter.present(result); + expect(presenter.responseModel).toEqual({ sponsors: [] }); presenter.reset(); - expect(() => presenter.viewModel).toThrow('Presenter not presented'); + expect(() => presenter.responseModel).toThrow('Presenter not presented'); }); }); describe('present', () => { - it('should store the result', () => { - const mockResult = { + it('should map Result.ok sponsors to DTO responseModel', () => { + const result = Result.ok({ sponsors: [ - { id: 'sponsor-1', name: 'Sponsor One', contactEmail: 's1@example.com' }, - { id: 'sponsor-2', name: 'Sponsor Two', contactEmail: 's2@example.com' }, + { + id: 'sponsor-1', + name: 'Sponsor One', + contactEmail: 's1@example.com', + logoUrl: 'logo1.png', + websiteUrl: 'https://one.example.com', + createdAt: new Date('2024-01-01T00:00:00Z'), + }, + { + id: 'sponsor-2', + name: 'Sponsor Two', + contactEmail: 's2@example.com', + logoUrl: undefined, + websiteUrl: undefined, + createdAt: undefined, + }, ], - }; + }); - presenter.present(mockResult); + presenter.present(result); - expect(presenter.viewModel).toEqual(mockResult); + expect(presenter.responseModel).toEqual({ + sponsors: [ + { + id: 'sponsor-1', + name: 'Sponsor One', + contactEmail: 's1@example.com', + logoUrl: 'logo1.png', + websiteUrl: 'https://one.example.com', + createdAt: new Date('2024-01-01T00:00:00Z'), + }, + { + id: 'sponsor-2', + name: 'Sponsor Two', + contactEmail: 's2@example.com', + logoUrl: undefined, + websiteUrl: undefined, + createdAt: undefined, + }, + ], + }); }); }); - describe('getViewModel', () => { + describe('getResponseModel', () => { it('should return null when not presented', () => { - expect(presenter.getViewModel()).toBeNull(); + expect(presenter.getResponseModel()).toBeNull(); }); - it('should return the result when presented', () => { - const mockResult = { sponsors: [] }; - presenter.present(mockResult); + it('should return the model when presented', () => { + const result = Result.ok({ sponsors: [] }); + presenter.present(result); - expect(presenter.getViewModel()).toEqual(mockResult); + expect(presenter.getResponseModel()).toEqual({ sponsors: [] }); }); }); - describe('viewModel', () => { + describe('responseModel', () => { it('should throw error when not presented', () => { - expect(() => presenter.viewModel).toThrow('Presenter not presented'); + expect(() => presenter.responseModel).toThrow('Presenter not presented'); }); - it('should return the result when presented', () => { - const mockResult = { sponsors: [] }; - presenter.present(mockResult); + it('should fallback to empty sponsors list on error', () => { + const error = { + code: 'REPOSITORY_ERROR' as GetSponsorsErrorCode, + details: { message: 'DB error' }, + } satisfies ApplicationErrorCode; + const result = Result.err(error); - expect(presenter.viewModel).toEqual(mockResult); + presenter.present(result); + + expect(presenter.responseModel).toEqual({ sponsors: [] }); }); }); }); \ No newline at end of file diff --git a/apps/api/src/domain/sponsor/presenters/GetSponsorsPresenter.ts b/apps/api/src/domain/sponsor/presenters/GetSponsorsPresenter.ts index 40ea18418..7897c2cd2 100644 --- a/apps/api/src/domain/sponsor/presenters/GetSponsorsPresenter.ts +++ b/apps/api/src/domain/sponsor/presenters/GetSponsorsPresenter.ts @@ -1,25 +1,51 @@ -import type { GetSponsorsOutputPort } from '@core/racing/application/ports/output/GetSponsorsOutputPort'; +import type { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { + GetSponsorsResult, + GetSponsorsErrorCode, +} from '@core/racing/application/use-cases/GetSponsorsUseCase'; import { GetSponsorsOutputDTO } from '../dtos/GetSponsorsOutputDTO'; +import type { SponsorDTO } from '../dtos/SponsorDTO'; export class GetSponsorsPresenter { - private result: GetSponsorsOutputDTO | null = null; + private model: GetSponsorsOutputDTO | null = null; reset() { - this.result = null; + this.model = null; } - present(outputPort: GetSponsorsOutputPort) { - this.result = { - sponsors: outputPort.sponsors, + present( + result: Result< + GetSponsorsResult, + ApplicationErrorCode + >, + ): void { + if (result.isErr()) { + // For sponsor listing, fall back to empty list on error + this.model = { sponsors: [] }; + return; + } + + const output = result.unwrap(); + + this.model = { + sponsors: output.sponsors.map((sponsor) => ({ + id: sponsor.id, + name: sponsor.name, + contactEmail: sponsor.contactEmail, + logoUrl: sponsor.logoUrl, + websiteUrl: sponsor.websiteUrl, + createdAt: sponsor.createdAt, + })), }; } - getViewModel(): GetSponsorsOutputDTO | null { - return this.result; + getResponseModel(): GetSponsorsOutputDTO | null { + return this.model; } - get viewModel(): GetSponsorsOutputDTO { - if (!this.result) throw new Error('Presenter not presented'); - return this.result; + get responseModel(): GetSponsorsOutputDTO { + if (!this.model) throw new Error('Presenter not presented'); + return this.model; } } diff --git a/apps/api/src/domain/team/TeamController.ts b/apps/api/src/domain/team/TeamController.ts index fdf68e8f6..2bb53e258 100644 --- a/apps/api/src/domain/team/TeamController.ts +++ b/apps/api/src/domain/team/TeamController.ts @@ -23,7 +23,7 @@ export class TeamController { @ApiResponse({ status: 200, description: 'List of all teams', type: GetAllTeamsOutputDTO }) async getAll(): Promise { const presenter = await this.teamService.getAll(); - return presenter.viewModel; + return presenter.responseModel; } @Get(':teamId') @@ -33,7 +33,7 @@ export class TeamController { async getDetails(@Param('teamId') teamId: string, @Req() req: Request): Promise { const userId = req['user']?.userId; const presenter = await this.teamService.getDetails(teamId, userId); - return presenter.getViewModel(); + return presenter.getResponseModel(); } @Get(':teamId/members') @@ -41,7 +41,7 @@ export class TeamController { @ApiResponse({ status: 200, description: 'Team members', type: GetTeamMembersOutputDTO }) async getMembers(@Param('teamId') teamId: string): Promise { const presenter = await this.teamService.getMembers(teamId); - return presenter.getViewModel()!; + return presenter.getResponseModel()!; } @Get(':teamId/join-requests') @@ -49,7 +49,7 @@ export class TeamController { @ApiResponse({ status: 200, description: 'Team join requests', type: GetTeamJoinRequestsOutputDTO }) async getJoinRequests(@Param('teamId') teamId: string): Promise { const presenter = await this.teamService.getJoinRequests(teamId); - return presenter.getViewModel()!; + return presenter.getResponseModel()!; } @Post() @@ -58,7 +58,7 @@ export class TeamController { async create(@Body() input: CreateTeamInputDTO, @Req() req: Request): Promise { const userId = req['user']?.userId; const presenter = await this.teamService.create(input, userId); - return presenter.viewModel; + return presenter.responseModel; } @Patch(':teamId') @@ -67,7 +67,7 @@ export class TeamController { async update(@Param('teamId') teamId: string, @Body() input: UpdateTeamInputDTO, @Req() req: Request): Promise { const userId = req['user']?.userId; const presenter = await this.teamService.update(teamId, input, userId); - return presenter.viewModel; + return presenter.responseModel; } @Get('driver/:driverId') @@ -76,15 +76,15 @@ export class TeamController { @ApiResponse({ status: 404, description: 'Team not found' }) async getDriverTeam(@Param('driverId') driverId: string): Promise { const presenter = await this.teamService.getDriverTeam(driverId); - return presenter.getViewModel(); + return presenter.getResponseModel(); } - + @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 { const presenter = await this.teamService.getMembership(teamId, driverId); - return presenter.viewModel; + return presenter.responseModel; } } \ No newline at end of file diff --git a/apps/api/src/domain/team/TeamService.ts b/apps/api/src/domain/team/TeamService.ts index 4bc67dae9..2fbe059c0 100644 --- a/apps/api/src/domain/team/TeamService.ts +++ b/apps/api/src/domain/team/TeamService.ts @@ -1,6 +1,14 @@ import { Injectable, Inject } from '@nestjs/common'; import { CreateTeamInputDTO } from './dtos/CreateTeamInputDTO'; import { UpdateTeamInputDTO } from './dtos/UpdateTeamInputDTO'; +import { GetAllTeamsOutputDTO } from './dtos/GetAllTeamsOutputDTO'; +import { GetTeamDetailsOutputDTO } from './dtos/GetTeamDetailsOutputDTO'; +import { GetTeamMembersOutputDTO } from './dtos/GetTeamMembersOutputDTO'; +import { GetTeamJoinRequestsOutputDTO } from './dtos/GetTeamJoinRequestsOutputDTO'; +import { CreateTeamOutputDTO } from './dtos/CreateTeamOutputDTO'; +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'; @@ -42,19 +50,19 @@ export class TeamService { @Inject(LOGGER_TOKEN) private readonly logger: Logger, ) {} - async getAll(): Promise { + async getAll(): Promise { // TODO: type this.logger.debug('[TeamService] Fetching all teams.'); - const presenter = new AllTeamsPresenter(); const result = await this.getAllTeamsUseCase.execute(); + const presenter = new AllTeamsPresenter(); if (result.isErr()) { this.logger.error('Error fetching all teams', result.error); await presenter.present({ teams: [], totalCount: 0 }); - return presenter; + return presenter.responseModel; } await presenter.present(result.value); - return presenter; + return presenter.responseModel; } async getDetails(teamId: string, userId?: string): Promise { diff --git a/apps/api/src/domain/team/presenters/AllTeamsPresenter.ts b/apps/api/src/domain/team/presenters/AllTeamsPresenter.ts index 0fd910e77..70a916820 100644 --- a/apps/api/src/domain/team/presenters/AllTeamsPresenter.ts +++ b/apps/api/src/domain/team/presenters/AllTeamsPresenter.ts @@ -1,15 +1,26 @@ -import { GetAllTeamsOutputPort } from '@core/racing/application/ports/output/GetAllTeamsOutputPort'; +import type { GetAllTeamsErrorCode, GetAllTeamsResult } from '@core/racing/application/use-cases/GetAllTeamsUseCase'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import { GetAllTeamsOutputDTO } from '../dtos/GetAllTeamsOutputDTO'; -export class AllTeamsPresenter { - private result: GetAllTeamsOutputDTO | null = null; +export type GetAllTeamsError = ApplicationErrorCode; - reset() { - this.result = null; +export class AllTeamsPresenter { + private model: GetAllTeamsOutputDTO | null = null; + + reset(): void { + this.model = null; } - async present(output: GetAllTeamsOutputPort) { - this.result = { + present(result: Result): void { + if (result.isErr()) { + const error = result.unwrapErr(); + throw new Error(error.details?.message ?? 'Failed to get teams'); + } + + const output = result.unwrap(); + + this.model = { teams: output.teams.map(team => ({ id: team.id, name: team.name, @@ -17,18 +28,18 @@ export class AllTeamsPresenter { description: team.description, memberCount: team.memberCount, leagues: team.leagues || [], - // Note: specialization, region, languages not available in output port + // Note: specialization, region, languages not available in output })), - totalCount: output.totalCount || output.teams.length, + totalCount: output.totalCount ?? output.teams.length, }; } - getViewModel(): GetAllTeamsOutputDTO | null { - return this.result; + getResponseModel(): GetAllTeamsOutputDTO | null { + return this.model; } - get viewModel(): GetAllTeamsOutputDTO { - if (!this.result) throw new Error('Presenter not presented'); - return this.result; + get responseModel(): GetAllTeamsOutputDTO { + if (!this.model) throw new Error('Presenter not presented'); + return this.model; } } \ 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 index df1c0d951..0d57a1425 100644 --- a/apps/api/src/domain/team/presenters/CreateTeamPresenter.ts +++ b/apps/api/src/domain/team/presenters/CreateTeamPresenter.ts @@ -1,36 +1,49 @@ -import type { CreateTeamOutputPort } from '@core/racing/application/ports/output/CreateTeamOutputPort'; +import type { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { CreateTeamErrorCode, CreateTeamResult } from '@core/racing/application/use-cases/CreateTeamUseCase'; import type { CreateTeamOutputDTO } from '../dtos/CreateTeamOutputDTO'; +export type CreateTeamError = ApplicationErrorCode; + export class CreateTeamPresenter { - private result: CreateTeamOutputDTO | null = null; + private model: CreateTeamOutputDTO | null = null; reset(): void { - this.result = null; + this.model = null; } - presentSuccess(output: CreateTeamOutputPort): void { - this.result = { + present(result: Result): void { + if (result.isErr()) { + const error = result.unwrapErr(); + // Validation and expected domain errors map to an unsuccessful DTO + if (error.code === 'VALIDATION_ERROR' || error.code === 'LEAGUE_NOT_FOUND') { + this.model = { + id: '', + success: false, + }; + return; + } + + throw new Error(error.details?.message ?? 'Failed to create team'); + } + + const output = result.unwrap(); + + this.model = { id: output.team.id, success: true, }; } - presentError(): void { - this.result = { - id: '', - success: false, - }; + getResponseModel(): CreateTeamOutputDTO | null { + return this.model; } - getViewModel(): CreateTeamOutputDTO | null { - return this.result; - } - - get viewModel(): CreateTeamOutputDTO { - if (!this.result) { + get responseModel(): CreateTeamOutputDTO { + if (!this.model) { throw new Error('Presenter not presented'); } - return this.result; + return this.model; } } diff --git a/apps/api/src/presentation/hello.controller.ts b/apps/api/src/presentation/hello.controller.ts index a72a10126..a44a05fa5 100644 --- a/apps/api/src/presentation/hello.controller.ts +++ b/apps/api/src/presentation/hello.controller.ts @@ -10,6 +10,6 @@ export class HelloController { @Get() getHello(): { message: string } { const presenter = this.helloService.getHello(); - return presenter.viewModel; + return presenter.responseModel; } } \ No newline at end of file diff --git a/apps/api/src/presentation/payments/CreatePaymentPresenter.ts b/apps/api/src/presentation/payments/CreatePaymentPresenter.ts new file mode 100644 index 000000000..2e6f6a5a3 --- /dev/null +++ b/apps/api/src/presentation/payments/CreatePaymentPresenter.ts @@ -0,0 +1,38 @@ +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { CreatePaymentResult } from '@core/payments/application/use-cases/CreatePaymentUseCase'; +import type { CreatePaymentViewModel, PaymentDto } from './types'; + +export class CreatePaymentPresenter implements UseCaseOutputPort { + private viewModel: CreatePaymentViewModel | null = null; + + present(result: CreatePaymentResult): void { + this.viewModel = { + payment: this.mapPaymentToDto(result.payment), + }; + } + + getViewModel(): CreatePaymentViewModel | null { + return this.viewModel; + } + + reset(): void { + this.viewModel = null; + } + + private mapPaymentToDto(payment: CreatePaymentResult['payment']): PaymentDto { + return { + id: payment.id, + type: payment.type, + amount: payment.amount, + platformFee: payment.platformFee, + netAmount: payment.netAmount, + payerId: payment.payerId, + payerType: payment.payerType, + leagueId: payment.leagueId, + seasonId: payment.seasonId, + status: payment.status, + createdAt: payment.createdAt, + completedAt: payment.completedAt, + }; + } +} \ No newline at end of file diff --git a/apps/api/src/presentation/payments/GetPaymentsPresenter.ts b/apps/api/src/presentation/payments/GetPaymentsPresenter.ts new file mode 100644 index 000000000..979a53b1a --- /dev/null +++ b/apps/api/src/presentation/payments/GetPaymentsPresenter.ts @@ -0,0 +1,38 @@ +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { GetPaymentsResult } from '@core/payments/application/use-cases/GetPaymentsUseCase'; +import type { GetPaymentsViewModel, PaymentDto } from './types'; + +export class GetPaymentsPresenter implements UseCaseOutputPort { + private viewModel: GetPaymentsViewModel | null = null; + + present(result: GetPaymentsResult): void { + this.viewModel = { + payments: result.payments.map(payment => this.mapPaymentToDto(payment)), + }; + } + + getViewModel(): GetPaymentsViewModel | null { + return this.viewModel; + } + + reset(): void { + this.viewModel = null; + } + + private mapPaymentToDto(payment: GetPaymentsResult['payments'][0]): PaymentDto { + return { + id: payment.id, + type: payment.type, + amount: payment.amount, + platformFee: payment.platformFee, + netAmount: payment.netAmount, + payerId: payment.payerId, + payerType: payment.payerType, + leagueId: payment.leagueId, + seasonId: payment.seasonId, + status: payment.status, + createdAt: payment.createdAt, + completedAt: payment.completedAt, + }; + } +} \ No newline at end of file diff --git a/apps/api/src/presentation/payments/GetSponsorBillingPresenter.ts b/apps/api/src/presentation/payments/GetSponsorBillingPresenter.ts new file mode 100644 index 000000000..f3faba283 --- /dev/null +++ b/apps/api/src/presentation/payments/GetSponsorBillingPresenter.ts @@ -0,0 +1,19 @@ +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { GetSponsorBillingResult } from '@core/payments/application/use-cases/GetSponsorBillingUseCase'; +import type { SponsorBillingSummary } from './types'; + +export class GetSponsorBillingPresenter implements UseCaseOutputPort { + private viewModel: SponsorBillingSummary | null = null; + + present(result: GetSponsorBillingResult): void { + this.viewModel = result; + } + + getViewModel(): SponsorBillingSummary | null { + return this.viewModel; + } + + reset(): void { + this.viewModel = null; + } +} \ No newline at end of file diff --git a/apps/api/src/presentation/payments/index.ts b/apps/api/src/presentation/payments/index.ts new file mode 100644 index 000000000..1424ad873 --- /dev/null +++ b/apps/api/src/presentation/payments/index.ts @@ -0,0 +1,4 @@ +export * from './types'; +export * from './CreatePaymentPresenter'; +export * from './GetPaymentsPresenter'; +export * from './GetSponsorBillingPresenter'; \ No newline at end of file diff --git a/apps/api/src/presentation/payments/types.ts b/apps/api/src/presentation/payments/types.ts new file mode 100644 index 000000000..7c59e7e2b --- /dev/null +++ b/apps/api/src/presentation/payments/types.ts @@ -0,0 +1,177 @@ +import type { PaymentType, PayerType, PaymentStatus } from '@core/payments/domain/entities/Payment'; +import type { PrizeType } from '@core/payments/domain/entities/Prize'; +import type { TransactionType, ReferenceType } from '@core/payments/domain/entities/Wallet'; +import type { MembershipFeeType } from '@core/payments/domain/entities/MembershipFee'; +import type { MemberPaymentStatus } from '@core/payments/domain/entities/MemberPayment'; + +// DTOs for API responses + +export interface PaymentDto { + id: string; + type: PaymentType; + amount: number; + platformFee: number; + netAmount: number; + payerId: string; + payerType: PayerType; + leagueId: string; + seasonId: string | undefined; + status: PaymentStatus; + createdAt: Date; + completedAt: Date | undefined; +} + +export interface PrizeDto { + id: string; + leagueId: string; + seasonId: string; + position: number; + name: string; + amount: number; + type: PrizeType; + description: string | undefined; + awarded: boolean; + awardedTo: string | undefined; + awardedAt: Date | undefined; + createdAt: Date; +} + +export interface WalletDto { + id: string; + leagueId: string; + balance: number; + totalRevenue: number; + totalPlatformFees: number; + totalWithdrawn: number; + currency: string; + createdAt: Date; +} + +export interface TransactionDto { + id: string; + walletId: string; + type: TransactionType; + amount: number; + description: string; + referenceId: string | undefined; + referenceType: ReferenceType | undefined; + createdAt: Date; +} + +export interface MembershipFeeDto { + id: string; + leagueId: string; + seasonId: string | undefined; + type: MembershipFeeType; + amount: number; + enabled: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface MemberPaymentDto { + id: string; + feeId: string; + driverId: string; + amount: number; + platformFee: number; + netAmount: number; + status: MemberPaymentStatus; + dueDate: Date; + paidAt: Date | undefined; +} + +// View Models + +export interface CreatePaymentViewModel { + payment: PaymentDto; +} + +export interface GetPaymentsViewModel { + payments: PaymentDto[]; +} + +export interface GetPrizesViewModel { + prizes: PrizeDto[]; +} + +export interface CreatePrizeViewModel { + prize: PrizeDto; +} + +export interface AwardPrizeViewModel { + prize: PrizeDto; +} + +export interface DeletePrizeViewModel { + success: boolean; +} + +export interface GetWalletViewModel { + wallet: WalletDto; + transactions: TransactionDto[]; +} + +export interface ProcessWalletTransactionViewModel { + wallet: WalletDto; + transaction: TransactionDto; +} + +export interface GetMembershipFeesViewModel { + fee: MembershipFeeDto | null; + payments: MemberPaymentDto[]; +} + +export interface UpsertMembershipFeeViewModel { + fee: MembershipFeeDto; +} + +export interface UpdateMemberPaymentViewModel { + payment: MemberPaymentDto; +} + +export interface UpdatePaymentStatusViewModel { + payment: PaymentDto; +} + +// Sponsor Billing + +export interface SponsorBillingStats { + totalSpent: number; + pendingAmount: number; + nextPaymentDate: string | null; + nextPaymentAmount: number | null; + activeSponsorships: number; + averageMonthlySpend: number; +} + +export interface SponsorInvoiceSummary { + id: string; + invoiceNumber: string; + date: string; + dueDate: string; + amount: number; + vatAmount: number; + totalAmount: number; + status: 'paid' | 'pending' | 'overdue' | 'failed'; + description: string; + sponsorshipType: 'league' | 'team' | 'driver' | 'race' | 'platform'; + pdfUrl: string; +} + +export interface SponsorPaymentMethodSummary { + id: string; + type: 'card' | 'bank' | 'sepa'; + last4: string; + brand?: string; + isDefault: boolean; + expiryMonth?: number; + expiryYear?: number; + bankName?: string; +} + +export interface SponsorBillingSummary { + paymentMethods: SponsorPaymentMethodSummary[]; + invoices: SponsorInvoiceSummary[]; + stats: SponsorBillingStats; +} \ No newline at end of file diff --git a/core/analytics/application/use-cases/GetAnalyticsMetricsUseCase.test.ts b/core/analytics/application/use-cases/GetAnalyticsMetricsUseCase.test.ts index d29883419..e6c2f086a 100644 --- a/core/analytics/application/use-cases/GetAnalyticsMetricsUseCase.test.ts +++ b/core/analytics/application/use-cases/GetAnalyticsMetricsUseCase.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, type Mock } from 'vitest'; -import { GetAnalyticsMetricsUseCase, type GetAnalyticsMetricsInput } from './GetAnalyticsMetricsUseCase'; +import { GetAnalyticsMetricsUseCase, type GetAnalyticsMetricsInput, type GetAnalyticsMetricsOutput } from './GetAnalyticsMetricsUseCase'; import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository'; import type { Logger, UseCaseOutputPort } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; @@ -16,7 +16,7 @@ describe('GetAnalyticsMetricsUseCase', () => { getBounceRate: Mock; }; let logger: Logger; - let output: UseCaseOutputPort> & { present: Mock }; + let output: UseCaseOutputPort & { present: Mock }; let useCase: GetAnalyticsMetricsUseCase; beforeEach(() => { @@ -44,14 +44,21 @@ describe('GetAnalyticsMetricsUseCase', () => { useCase = new GetAnalyticsMetricsUseCase( pageViewRepository as unknown as IPageViewRepository, - output, logger, + output, ); }); it('presents default metrics and logs retrieval when no input is provided', async () => { - await useCase.execute(); + const result = await useCase.execute(); + expect(result.isOk()).toBe(true); + expect(output.present).toHaveBeenCalledWith({ + pageViews: 0, + uniqueVisitors: 0, + averageSessionDuration: 0, + bounceRate: 0, + }); expect((logger.info as unknown as Mock)).toHaveBeenCalled(); }); @@ -66,8 +73,9 @@ describe('GetAnalyticsMetricsUseCase', () => { throw new Error('Logging failed'); }); - await useCase.execute(input); + const result = await useCase.execute(input); + expect(result.isErr()).toBe(true); expect((logger.error as unknown as Mock)).toHaveBeenCalled(); }); }); diff --git a/core/analytics/application/use-cases/GetAnalyticsMetricsUseCase.ts b/core/analytics/application/use-cases/GetAnalyticsMetricsUseCase.ts index 2da28c353..cdd4d5cf0 100644 --- a/core/analytics/application/use-cases/GetAnalyticsMetricsUseCase.ts +++ b/core/analytics/application/use-cases/GetAnalyticsMetricsUseCase.ts @@ -1,7 +1,7 @@ -import type { Logger, UseCaseOutputPort, UseCase } from '@core/shared/application'; -import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository'; +import type { Logger, UseCase, UseCaseOutputPort } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository'; export interface GetAnalyticsMetricsInput { startDate?: Date; @@ -17,25 +17,33 @@ export interface GetAnalyticsMetricsOutput { export type GetAnalyticsMetricsErrorCode = 'REPOSITORY_ERROR'; -export class GetAnalyticsMetricsUseCase implements UseCase { +export class GetAnalyticsMetricsUseCase implements UseCase { constructor( private readonly pageViewRepository: IPageViewRepository, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(input: GetAnalyticsMetricsInput = {}): Promise>> { + async execute(input: GetAnalyticsMetricsInput = {}): Promise>> { try { const startDate = input.startDate ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days ago const endDate = input.endDate ?? new Date(); - // For now, return placeholder values as actual implementation would require - // aggregating data across all entities or specifying which entity - // This is a simplified version + // TODO static data const pageViews = 0; const uniqueVisitors = 0; const averageSessionDuration = 0; const bounceRate = 0; + const resultModel: GetAnalyticsMetricsOutput = { + pageViews, + uniqueVisitors, + averageSessionDuration, + bounceRate, + }; + + this.output.present(resultModel); + this.logger.info('Analytics metrics retrieved', { startDate, endDate, @@ -43,21 +51,14 @@ export class GetAnalyticsMetricsUseCase implements UseCase>({ - pageViews, - uniqueVisitors, - averageSessionDuration, - bounceRate, - }); - return result; + return Result.ok(undefined); } catch (error) { const err = error as Error; this.logger.error('Failed to get analytics metrics', err, { input }); - const result = Result.err>({ + return Result.err({ code: 'REPOSITORY_ERROR', details: { message: err.message ?? 'Failed to get analytics metrics' }, }); - return result; } } } \ No newline at end of file diff --git a/core/analytics/application/use-cases/GetDashboardDataUseCase.test.ts b/core/analytics/application/use-cases/GetDashboardDataUseCase.test.ts index 91068b98c..e1faf66fa 100644 --- a/core/analytics/application/use-cases/GetDashboardDataUseCase.test.ts +++ b/core/analytics/application/use-cases/GetDashboardDataUseCase.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, vi, type Mock } from 'vitest'; -import { GetDashboardDataUseCase } from './GetDashboardDataUseCase'; +import { GetDashboardDataUseCase, type GetDashboardDataOutput } from './GetDashboardDataUseCase'; import type { Logger, UseCaseOutputPort } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; describe('GetDashboardDataUseCase', () => { let logger: Logger; - let output: UseCaseOutputPort> & { present: Mock }; + let output: UseCaseOutputPort & { present: Mock }; let useCase: GetDashboardDataUseCase; beforeEach(() => { @@ -20,18 +20,19 @@ describe('GetDashboardDataUseCase', () => { present: vi.fn(), }; - useCase = new GetDashboardDataUseCase(output, logger); + useCase = new GetDashboardDataUseCase(logger, output); }); it('presents placeholder dashboard metrics and logs retrieval', async () => { - await useCase.execute(); + const result = await useCase.execute(); - expect(output.present).toHaveBeenCalledWith(Result.ok({ + expect(result.isOk()).toBe(true); + expect(output.present).toHaveBeenCalledWith({ totalUsers: 0, activeUsers: 0, totalRaces: 0, totalLeagues: 0, - })); + }); expect((logger.info as unknown as Mock)).toHaveBeenCalled(); }); diff --git a/core/analytics/application/use-cases/GetDashboardDataUseCase.ts b/core/analytics/application/use-cases/GetDashboardDataUseCase.ts index 831ae61f7..a912931a4 100644 --- a/core/analytics/application/use-cases/GetDashboardDataUseCase.ts +++ b/core/analytics/application/use-cases/GetDashboardDataUseCase.ts @@ -13,12 +13,13 @@ export interface GetDashboardDataOutput { export type GetDashboardDataErrorCode = 'REPOSITORY_ERROR'; -export class GetDashboardDataUseCase implements UseCase { +export class GetDashboardDataUseCase implements UseCase { constructor( private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(input: GetDashboardDataInput = {}): Promise>> { + async execute(input: GetDashboardDataInput = {}): Promise>> { try { // Placeholder implementation - would need repositories from identity and racing domains const totalUsers = 0; @@ -26,6 +27,15 @@ export class GetDashboardDataUseCase implements UseCase>({ - totalUsers, - activeUsers, - totalRaces, - totalLeagues, - }); - return result; + return Result.ok(undefined); } catch (error) { const err = error as Error; this.logger.error('Failed to get dashboard data', err); - const result = Result.err>({ + return Result.err({ code: 'REPOSITORY_ERROR', details: { message: err.message ?? 'Failed to get dashboard data' }, }); - return result; } } } \ No newline at end of file diff --git a/core/analytics/application/use-cases/GetEntityAnalyticsQuery.test.ts b/core/analytics/application/use-cases/GetEntityAnalyticsQuery.test.ts index ecfc011f8..0920d88d2 100644 --- a/core/analytics/application/use-cases/GetEntityAnalyticsQuery.test.ts +++ b/core/analytics/application/use-cases/GetEntityAnalyticsQuery.test.ts @@ -66,20 +66,22 @@ describe('GetEntityAnalyticsQuery', () => { const result = await useCase.execute(input); - expect(result.entityId).toBe(input.entityId); - expect(result.entityType).toBe(input.entityType); + expect(result.isOk()).toBe(true); + const data = result.unwrap(); + expect(data.entityId).toBe(input.entityId); + expect(data.entityType).toBe(input.entityType); - expect(result.summary.totalPageViews).toBe(100); - expect(result.summary.uniqueVisitors).toBe(40); - expect(result.summary.sponsorClicks).toBe(10); - expect(typeof result.summary.engagementScore).toBe('number'); - expect(result.summary.exposureValue).toBeGreaterThan(0); + expect(data.summary.totalPageViews).toBe(100); + expect(data.summary.uniqueVisitors).toBe(40); + expect(data.summary.sponsorClicks).toBe(10); + expect(typeof data.summary.engagementScore).toBe('number'); + expect(data.summary.exposureValue).toBeGreaterThan(0); - expect(result.trends.pageViewsChange).toBeDefined(); - expect(result.trends.uniqueVisitorsChange).toBeDefined(); + expect(data.trends.pageViewsChange).toBeDefined(); + expect(data.trends.uniqueVisitorsChange).toBeDefined(); - expect(result.period.start).toBeInstanceOf(Date); - expect(result.period.end).toBeInstanceOf(Date); + expect(data.period.start).toBeInstanceOf(Date); + expect(data.period.end).toBeInstanceOf(Date); }); it('propagates repository errors', async () => { @@ -90,7 +92,9 @@ describe('GetEntityAnalyticsQuery', () => { pageViewRepository.countByEntityId.mockRejectedValue(new Error('DB error')); - await expect(useCase.execute(input)).rejects.toThrow('DB error'); + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); expect((logger.error as unknown as Mock)).toHaveBeenCalled(); }); }); diff --git a/core/analytics/application/use-cases/GetEntityAnalyticsQuery.ts b/core/analytics/application/use-cases/GetEntityAnalyticsQuery.ts index 37b5b9422..e72353153 100644 --- a/core/analytics/application/use-cases/GetEntityAnalyticsQuery.ts +++ b/core/analytics/application/use-cases/GetEntityAnalyticsQuery.ts @@ -52,7 +52,6 @@ export class GetEntityAnalyticsQuery private readonly pageViewRepository: IPageViewRepository, private readonly engagementRepository: IEngagementRepository, private readonly snapshotRepository: IAnalyticsSnapshotRepository, - private readonly output: UseCaseOutputPort>>, private readonly logger: Logger ) {} @@ -145,10 +144,8 @@ export class GetEntityAnalyticsQuery label: this.formatPeriodLabel(since, now), }, }; - const result = Result.ok>(resultData); - this.output.present(result); this.logger.info(`Successfully retrieved analytics for entity ${input.entityId}.`); - return result; + return Result.ok(resultData); } catch (error) { const err = error as Error; this.logger.error(`Failed to get entity analytics for ${input.entityId}: ${err.message}`, err); @@ -156,7 +153,6 @@ export class GetEntityAnalyticsQuery code: 'REPOSITORY_ERROR', details: { message: err.message ?? 'Failed to get entity analytics' }, }); - this.output.present(result); return result; } } diff --git a/core/analytics/application/use-cases/RecordEngagementUseCase.test.ts b/core/analytics/application/use-cases/RecordEngagementUseCase.test.ts index 6bf7ee9ec..725b145ad 100644 --- a/core/analytics/application/use-cases/RecordEngagementUseCase.test.ts +++ b/core/analytics/application/use-cases/RecordEngagementUseCase.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, type Mock } from 'vitest'; -import { RecordEngagementUseCase, type RecordEngagementInput } from './RecordEngagementUseCase'; +import { RecordEngagementUseCase, type RecordEngagementInput, type RecordEngagementOutput } from './RecordEngagementUseCase'; import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository'; import { EngagementEvent } from '../../domain/entities/EngagementEvent'; import type { Logger, UseCaseOutputPort } from '@core/shared/application'; @@ -11,7 +11,7 @@ describe('RecordEngagementUseCase', () => { save: Mock; }; let logger: Logger; - let output: UseCaseOutputPort> & { present: Mock }; + let output: UseCaseOutputPort & { present: Mock }; let useCase: RecordEngagementUseCase; beforeEach(() => { @@ -32,8 +32,8 @@ describe('RecordEngagementUseCase', () => { useCase = new RecordEngagementUseCase( engagementRepository as unknown as IEngagementRepository, - output, logger, + output, ); }); @@ -50,8 +50,9 @@ describe('RecordEngagementUseCase', () => { engagementRepository.save.mockResolvedValue(undefined); - await useCase.execute(input); + const result = await useCase.execute(input); + expect(result.isOk()).toBe(true); expect(engagementRepository.save).toHaveBeenCalledTimes(1); const saved = (engagementRepository.save as unknown as Mock).mock.calls[0][0] as EngagementEvent; @@ -60,6 +61,10 @@ describe('RecordEngagementUseCase', () => { expect(saved.entityId).toBe(input.entityId); expect(saved.entityType).toBe(input.entityType); + expect(output.present).toHaveBeenCalledWith({ + eventId: saved.id, + engagementWeight: saved.getEngagementWeight(), + }); expect((logger.info as unknown as Mock)).toHaveBeenCalled(); }); @@ -75,8 +80,9 @@ describe('RecordEngagementUseCase', () => { const error = new Error('DB error'); engagementRepository.save.mockRejectedValue(error); - await useCase.execute(input); + const result = await useCase.execute(input); + expect(result.isErr()).toBe(true); expect((logger.error as unknown as Mock)).toHaveBeenCalled(); }); }); diff --git a/core/analytics/application/use-cases/RecordEngagementUseCase.ts b/core/analytics/application/use-cases/RecordEngagementUseCase.ts index b7b62c9fe..2fa9fb958 100644 --- a/core/analytics/application/use-cases/RecordEngagementUseCase.ts +++ b/core/analytics/application/use-cases/RecordEngagementUseCase.ts @@ -22,13 +22,14 @@ export interface RecordEngagementOutput { export type RecordEngagementErrorCode = 'REPOSITORY_ERROR'; -export class RecordEngagementUseCase implements UseCase { +export class RecordEngagementUseCase implements UseCase { constructor( private readonly engagementRepository: IEngagementRepository, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(input: RecordEngagementInput): Promise>> { + async execute(input: RecordEngagementInput): Promise>> { try { const engagementEvent = EngagementEvent.create({ id: crypto.randomUUID(), @@ -43,6 +44,13 @@ export class RecordEngagementUseCase implements UseCase>({ - eventId: engagementEvent.id, - engagementWeight: engagementEvent.getEngagementWeight(), - }); - return result; + return Result.ok(undefined); } catch (error) { const err = error as Error; this.logger.error('Failed to record engagement event', err, { input }); - const result = Result.err>({ + return Result.err({ code: 'REPOSITORY_ERROR', details: { message: err.message ?? 'Failed to record engagement event' }, }); - return result; } } } \ No newline at end of file diff --git a/core/analytics/application/use-cases/RecordPageViewUseCase.test.ts b/core/analytics/application/use-cases/RecordPageViewUseCase.test.ts index f1d83e0fb..2d964cee5 100644 --- a/core/analytics/application/use-cases/RecordPageViewUseCase.test.ts +++ b/core/analytics/application/use-cases/RecordPageViewUseCase.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, type Mock } from 'vitest'; -import { RecordPageViewUseCase, type RecordPageViewInput } from './RecordPageViewUseCase'; +import { RecordPageViewUseCase, type RecordPageViewInput, type RecordPageViewOutput } from './RecordPageViewUseCase'; import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository'; import { PageView } from '../../domain/entities/PageView'; import type { Logger, UseCaseOutputPort } from '@core/shared/application'; @@ -11,7 +11,7 @@ describe('RecordPageViewUseCase', () => { save: Mock; }; let logger: Logger; - let output: UseCaseOutputPort> & { present: Mock }; + let output: UseCaseOutputPort & { present: Mock }; let useCase: RecordPageViewUseCase; beforeEach(() => { @@ -32,8 +32,8 @@ describe('RecordPageViewUseCase', () => { useCase = new RecordPageViewUseCase( pageViewRepository as unknown as IPageViewRepository, - output, logger, + output, ); }); @@ -51,8 +51,9 @@ describe('RecordPageViewUseCase', () => { pageViewRepository.save.mockResolvedValue(undefined); - await useCase.execute(input); + const result = await useCase.execute(input); + expect(result.isOk()).toBe(true); expect(pageViewRepository.save).toHaveBeenCalledTimes(1); const saved = (pageViewRepository.save as unknown as Mock).mock.calls[0][0] as PageView; @@ -61,6 +62,9 @@ describe('RecordPageViewUseCase', () => { expect(saved.entityId).toBe(input.entityId); expect(saved.entityType).toBe(input.entityType); + expect(output.present).toHaveBeenCalledWith({ + pageViewId: saved.id, + }); expect((logger.info as unknown as Mock)).toHaveBeenCalled(); }); @@ -75,8 +79,9 @@ describe('RecordPageViewUseCase', () => { const error = new Error('DB error'); pageViewRepository.save.mockRejectedValue(error); - await useCase.execute(input); + const result = await useCase.execute(input); + expect(result.isErr()).toBe(true); expect((logger.error as unknown as Mock)).toHaveBeenCalled(); }); }); diff --git a/core/analytics/application/use-cases/RecordPageViewUseCase.ts b/core/analytics/application/use-cases/RecordPageViewUseCase.ts index a6c926a52..3a533afce 100644 --- a/core/analytics/application/use-cases/RecordPageViewUseCase.ts +++ b/core/analytics/application/use-cases/RecordPageViewUseCase.ts @@ -22,13 +22,14 @@ export interface RecordPageViewOutput { export type RecordPageViewErrorCode = 'REPOSITORY_ERROR'; -export class RecordPageViewUseCase implements UseCase { +export class RecordPageViewUseCase implements UseCase { constructor( private readonly pageViewRepository: IPageViewRepository, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(input: RecordPageViewInput): Promise>> { + async execute(input: RecordPageViewInput): Promise>> { try { const pageView = PageView.create({ id: crypto.randomUUID(), @@ -44,24 +45,26 @@ export class RecordPageViewUseCase implements UseCase>({ - pageViewId: pageView.id, - }); - return result; + return Result.ok(undefined); } catch (error) { const err = error as Error; this.logger.error('Failed to record page view', err, { input }); - const result = Result.err>({ + return Result.err({ code: 'REPOSITORY_ERROR', details: { message: err.message ?? 'Failed to record page view' }, }); - return result; } } } \ No newline at end of file diff --git a/core/identity/application/use-cases/LoginUseCase.ts b/core/identity/application/use-cases/LoginUseCase.ts index 3a18285bd..c7000b01c 100644 --- a/core/identity/application/use-cases/LoginUseCase.ts +++ b/core/identity/application/use-cases/LoginUseCase.ts @@ -24,43 +24,38 @@ export type LoginApplicationError = ApplicationErrorCode { +export class LoginUseCase implements UseCase { constructor( private readonly authRepo: IAuthRepository, private readonly passwordService: IPasswordHashingService, private readonly logger: Logger, - private readonly output: UseCaseOutputPort>, + private readonly output: UseCaseOutputPort, ) {} - async execute(input: LoginInput): Promise> { + async execute(input: LoginInput): Promise> { try { const emailVO = EmailAddress.create(input.email); const user = await this.authRepo.findByEmail(emailVO); if (!user || !user.getPasswordHash()) { - const result = Result.err({ + return Result.err({ code: 'INVALID_CREDENTIALS', details: { message: 'Invalid credentials' }, }); - this.output.present(result); - return result; } const passwordHash = user.getPasswordHash()!; const isValid = await this.passwordService.verify(input.password, passwordHash.value); if (!isValid) { - const result = Result.err({ + return Result.err({ code: 'INVALID_CREDENTIALS', details: { message: 'Invalid credentials' }, }); - this.output.present(result); - return result; } - const result = Result.ok({ user }); - this.output.present(result); - return result; + this.output.present({ user }); + return Result.ok(undefined); } catch (error) { const message = error instanceof Error && error.message @@ -71,12 +66,10 @@ export class LoginUseCase implements UseCase({ + return Result.err({ code: 'REPOSITORY_ERROR', details: { message }, }); - this.output.present(result); - return result; } } } \ No newline at end of file diff --git a/core/identity/application/use-cases/SignupUseCase.ts b/core/identity/application/use-cases/SignupUseCase.ts index 1b2aa8329..716fd09bd 100644 --- a/core/identity/application/use-cases/SignupUseCase.ts +++ b/core/identity/application/use-cases/SignupUseCase.ts @@ -26,26 +26,24 @@ export type SignupApplicationError = ApplicationErrorCode { +export class SignupUseCase implements UseCase { constructor( private readonly authRepo: IAuthRepository, private readonly passwordService: IPasswordHashingService, private readonly logger: Logger, - private readonly output: UseCaseOutputPort>, + private readonly output: UseCaseOutputPort, ) {} - async execute(input: SignupInput): Promise> { + async execute(input: SignupInput): Promise> { try { const emailVO = EmailAddress.create(input.email); const existingUser = await this.authRepo.findByEmail(emailVO); if (existingUser) { - const result = Result.err({ + return Result.err({ code: 'USER_ALREADY_EXISTS', details: { message: 'User already exists' }, }); - this.output.present(result); - return result; } const hashedPassword = await this.passwordService.hash(input.password); @@ -62,9 +60,8 @@ export class SignupUseCase implements UseCase({ user }); - this.output.present(result); - return result; + this.output.present({ user }); + return Result.ok(undefined); } catch (error) { const message = error instanceof Error && error.message @@ -75,12 +72,10 @@ export class SignupUseCase implements UseCase({ + return Result.err({ code: 'REPOSITORY_ERROR', details: { message }, }); - this.output.present(result); - return result; } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.ts b/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.ts index c017818f8..0558ca277 100644 --- a/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.ts +++ b/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.ts @@ -2,7 +2,7 @@ import type { IDriverRepository } from '../../domain/repositories/IDriverReposit import { Driver } from '../../domain/entities/Driver'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { UseCaseOutputPort, UseCase } from '@core/shared/application'; import type { Logger } from '@core/shared/application/Logger'; export interface CompleteDriverOnboardingInput { @@ -30,7 +30,7 @@ export type CompleteDriverOnboardingApplicationError = ApplicationErrorCode< /** * Use Case for completing driver onboarding. */ -export class CompleteDriverOnboardingUseCase { +export class CompleteDriverOnboardingUseCase implements UseCase { constructor( private readonly driverRepository: IDriverRepository, private readonly logger: Logger, diff --git a/core/racing/application/use-cases/DashboardOverviewUseCase.ts b/core/racing/application/use-cases/DashboardOverviewUseCase.ts index 40e95a164..a04009bf2 100644 --- a/core/racing/application/use-cases/DashboardOverviewUseCase.ts +++ b/core/racing/application/use-cases/DashboardOverviewUseCase.ts @@ -9,6 +9,7 @@ import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepo import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; import { League } from '../../domain/entities/League'; import { Race } from '../../domain/entities/Race'; import { Result as RaceResult } from '../../domain/entities/Result'; @@ -96,13 +97,14 @@ export class DashboardOverviewUseCase { private readonly getDriverStats: ( driverId: string, ) => DashboardDriverStatsAdapter | null, + private readonly output: UseCaseOutputPort, ) {} async execute( input: DashboardOverviewInput, ): Promise< Result< - DashboardOverviewResult, + void, ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }> > > { @@ -207,7 +209,9 @@ export class DashboardOverviewUseCase { friends: friendsSummary, }; - return Result.ok(result); + this.output.present(result); + + return Result.ok(undefined); } catch (error) { return Result.err({ code: 'REPOSITORY_ERROR', diff --git a/core/racing/application/use-cases/GetDriversLeaderboardUseCase.ts b/core/racing/application/use-cases/GetDriversLeaderboardUseCase.ts index c534c3890..51880bb01 100644 --- a/core/racing/application/use-cases/GetDriversLeaderboardUseCase.ts +++ b/core/racing/application/use-cases/GetDriversLeaderboardUseCase.ts @@ -1,16 +1,15 @@ -import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; -import type { IRankingService } from '../../domain/services/IRankingService'; -import type { IDriverStatsService } from '../../domain/services/IDriverStatsService'; -import { SkillLevelService, type SkillLevel } from '../../domain/services/SkillLevelService'; import type { Logger } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; import type { Driver } from '../../domain/entities/Driver'; import type { Team } from '../../domain/entities/Team'; +import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import type { IDriverStatsService } from '../../domain/services/IDriverStatsService'; +import type { IRankingService } from '../../domain/services/IRankingService'; +import { SkillLevelService, type SkillLevel } from '../../domain/services/SkillLevelService'; export type GetDriversLeaderboardInput = { - leagueId: string; + leagueId?: string; seasonId?: string; }; @@ -34,11 +33,14 @@ export interface GetDriversLeaderboardResult { activeCount: number; } -export type GetDriversLeaderboardErrorCode = 'LEAGUE_NOT_FOUND' | 'SEASON_NOT_FOUND' | 'REPOSITORY_ERROR'; +export type GetDriversLeaderboardErrorCode = + | 'LEAGUE_NOT_FOUND' + | 'SEASON_NOT_FOUND' + | 'REPOSITORY_ERROR'; /** * Use Case for retrieving driver leaderboard data. - * Orchestrates domain logic and returns result. + * Returns a Result containing the domain leaderboard model. */ export class GetDriversLeaderboardUseCase { constructor( @@ -47,13 +49,18 @@ export class GetDriversLeaderboardUseCase { private readonly driverStatsService: IDriverStatsService, private readonly getDriverAvatar: (driverId: string) => Promise, private readonly logger: Logger, - private readonly output: UseCaseOutputPort, ) {} async execute( - _input: GetDriversLeaderboardInput, - ): Promise>> { - this.logger.debug('Executing GetDriversLeaderboardUseCase'); + input: GetDriversLeaderboardInput, + ): Promise< + Result< + GetDriversLeaderboardResult, + ApplicationErrorCode + > + > { + this.logger.debug('Executing GetDriversLeaderboardUseCase', { input }); + try { const drivers = await this.driverRepository.findAll(); const rankings = this.rankingService.getAllDriverRankings(); @@ -64,12 +71,15 @@ export class GetDriversLeaderboardUseCase { avatarUrls[driver.id] = await this.getDriverAvatar(driver.id); } - const items: DriverLeaderboardItem[] = drivers.map((driver) => { - const ranking = rankings.find((r) => r.driverId === driver.id); + // TODO maps way too much data, should just create Domain Objects + + const items: DriverLeaderboardItem[] = drivers.map(driver => { + const ranking = rankings.find(r => r.driverId === driver.id); const stats = this.driverStatsService.getDriverStats(driver.id); const rating = ranking?.rating ?? 0; const racesCompleted = stats?.totalRaces ?? 0; const skillLevel: SkillLevel = SkillLevelService.getSkillLevel(rating); + const avatarUrl = avatarUrls[driver.id]; return { driver, @@ -80,30 +90,32 @@ export class GetDriversLeaderboardUseCase { podiums: stats?.podiums ?? 0, isActive: racesCompleted > 0, rank: ranking?.overallRank ?? 0, - avatarUrl: avatarUrls[driver.id], + ...(avatarUrl !== undefined ? { avatarUrl } : {}), }; }); const totalRaces = items.reduce((sum, d) => sum + d.racesCompleted, 0); const totalWins = items.reduce((sum, d) => sum + d.wins, 0); - const activeCount = items.filter((d) => d.isActive).length; + const activeCount = items.filter(d => d.isActive).length; - this.logger.debug('Successfully retrieved drivers leaderboard.'); - - return Result.ok({ + const result: GetDriversLeaderboardResult = { items: items.sort((a, b) => b.rating - a.rating), totalRaces, totalWins, activeCount, - }); + }; + + this.logger.debug('Successfully computed drivers leaderboard'); + + return Result.ok(result); } catch (error) { - this.logger.error( - 'Error executing GetDriversLeaderboardUseCase', - error instanceof Error ? error : new Error(String(error)), - ); + const err = error instanceof Error ? error : new Error(String(error)); + + this.logger.error('Error executing GetDriversLeaderboardUseCase', err); + return Result.err({ code: 'REPOSITORY_ERROR', - details: { message: error instanceof Error ? error.message : 'Unknown error occurred' }, + details: { message: err.message ?? 'Unknown error occurred' }, }); } } diff --git a/core/racing/application/use-cases/GetProfileOverviewUseCase.ts b/core/racing/application/use-cases/GetProfileOverviewUseCase.ts index 6d0e28208..4f6f4ad74 100644 --- a/core/racing/application/use-cases/GetProfileOverviewUseCase.ts +++ b/core/racing/application/use-cases/GetProfileOverviewUseCase.ts @@ -9,7 +9,7 @@ import type { Team } from '../../domain/entities/Team'; import type { TeamMembership } from '../../domain/types/TeamMembership'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { UseCaseOutputPort } from '@core/shared/application'; +import type { UseCaseOutputPort, UseCase } from '@core/shared/application'; interface ProfileDriverStatsAdapter { rating: number | null; @@ -92,7 +92,7 @@ export type GetProfileOverviewErrorCode = | 'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR'; -export class GetProfileOverviewUseCase { +export class GetProfileOverviewUseCase implements UseCase { constructor( private readonly driverRepository: IDriverRepository, private readonly teamRepository: ITeamRepository, diff --git a/core/racing/application/use-cases/GetTotalDriversUseCase.ts b/core/racing/application/use-cases/GetTotalDriversUseCase.ts index 0067d32b8..1bc4642a5 100644 --- a/core/racing/application/use-cases/GetTotalDriversUseCase.ts +++ b/core/racing/application/use-cases/GetTotalDriversUseCase.ts @@ -1,7 +1,7 @@ import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { UseCaseOutputPort } from '@core/shared/application'; +import type { UseCaseOutputPort, UseCase } from '@core/shared/application'; /** * Input type for retrieving total number of drivers. @@ -17,7 +17,7 @@ export type GetTotalDriversResult = { export type GetTotalDriversErrorCode = 'REPOSITORY_ERROR'; -export class GetTotalDriversUseCase { +export class GetTotalDriversUseCase implements UseCase { constructor( private readonly driverRepository: IDriverRepository, private readonly output: UseCaseOutputPort, diff --git a/core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase.ts b/core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase.ts index de3b06bcf..317dcfe0d 100644 --- a/core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase.ts +++ b/core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase.ts @@ -1,5 +1,5 @@ import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; -import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import type { Logger, UseCaseOutputPort, UseCase } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; @@ -26,7 +26,7 @@ export type IsDriverRegisteredForRaceResult = { * * Checks if a driver is registered for a specific race. */ -export class IsDriverRegisteredForRaceUseCase { +export class IsDriverRegisteredForRaceUseCase implements UseCase { constructor( private readonly registrationRepository: IRaceRegistrationRepository, private readonly logger: Logger, diff --git a/core/racing/application/use-cases/UpdateDriverProfileUseCase.ts b/core/racing/application/use-cases/UpdateDriverProfileUseCase.ts index 8f1959b98..4e6be252f 100644 --- a/core/racing/application/use-cases/UpdateDriverProfileUseCase.ts +++ b/core/racing/application/use-cases/UpdateDriverProfileUseCase.ts @@ -1,5 +1,5 @@ import { Result } from '@core/shared/application/Result'; -import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { UseCaseOutputPort, UseCase } from '@core/shared/application'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { Logger } from '@core/shared/application/Logger'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; @@ -25,7 +25,7 @@ export type UpdateDriverProfileErrorCode = * Encapsulates domain entity mutation. Mapping to DTOs is handled by presenters * in the presentation layer through the output port. */ -export class UpdateDriverProfileUseCase { +export class UpdateDriverProfileUseCase implements UseCase { constructor( private readonly driverRepository: IDriverRepository, private readonly output: UseCaseOutputPort, diff --git a/core/shared/application/UseCaseOutputPort.ts b/core/shared/application/UseCaseOutputPort.ts index 8140e3d22..abf9385b4 100644 --- a/core/shared/application/UseCaseOutputPort.ts +++ b/core/shared/application/UseCaseOutputPort.ts @@ -1,15 +1,11 @@ -import type { Result } from './Result'; -import type { ApplicationErrorCode } from '../errors/ApplicationErrorCode'; - /** * Output Port interface for use cases. * * Defines how the core communicates outward. A behavioral boundary that allows * the use case to present results without knowing the presentation details. * - * @template T - The success type in the Result - * @template E - The error code type + * @template T - The result model type */ -export interface UseCaseOutputPort { - present(result: Result>): any; +export interface UseCaseOutputPort { + present(data: T): void; } \ No newline at end of file diff --git a/plans/api-usecase-presenter-migration.md b/plans/api-usecase-presenter-migration.md new file mode 100644 index 000000000..7b4b6684a --- /dev/null +++ b/plans/api-usecase-presenter-migration.md @@ -0,0 +1,517 @@ +# API Use Case and Presenter Migration Todo List (Per File) + +This todo list is structured per domain module and per file. It is intentionally free of code snippets and focuses only on the structural and behavioral changes required. + +--- + +## Global cross-cutting tasks + +- [ ] Ensure every migrated use case in the core returns a Result type and uses an output port to present its business result model. +- [ ] Ensure all presenters live in the API layer, receive business result models from use cases via an output port, and store internal response models for the API. +- [ ] Ensure all services in the API layer delegate mapping and response model construction to presenters and do not perform DTO or response model mapping themselves. +- [ ] Ensure repositories and use cases are injected via dependency injection tokens only, never by direct class references. +- [ ] Ensure presenters are never injected into services via dependency injection; presenters should be imported directly where needed and bound as output ports in modules. +- [ ] Ensure use cases never perform serialization or DTO mapping; use cases operate on domain objects and result models only. +- [ ] After each module migration, run type checking, linting, and targeted tests for that module. +- [ ] After all modules are migrated, run full type checking, linting, and the entire test suite. + +--- + +## Analytics domain module + +Directory: apps/api/src/domain/analytics + +### Controllers + +- File: apps/api/src/domain/analytics/AnalyticsController.ts + - [ ] Review all controller methods and update them to consume response models returned from the analytics service rather than constructing or interpreting DTOs manually. + - [ ] Ensure controller method signatures and return types reflect the new response model naming and structure introduced by presenters. + +- File: apps/api/src/domain/analytics/AnalyticsController.test.ts + - [ ] Update tests to assert that controller methods receive response models from the service and do not depend on internal mapping logic inside the service. + - [ ] Adjust expectations to align with response model terminology instead of any previous view model terminology. + +### Services + +- File: apps/api/src/domain/analytics/AnalyticsService.ts + - [ ] Identify each method that calls a core analytics use case and ensure it passes the use case result through the appropriate presenter via the output port. + - [ ] Remove any mapping or DTO-building logic from the service methods; move all such responsibilities into dedicated analytics presenters. + - [ ] Ensure each service method returns only the presenter’s response model, not any core domain objects or intermediate data. + - [ ] Verify that all injected dependencies are repositories and use cases injected via tokens, with no presenters injected via dependency injection. + +### Module and providers + +- File: apps/api/src/domain/analytics/AnalyticsModule.ts + - [ ] Ensure the module declares analytics presenters as providers and binds them as implementations of the generic output port for the relevant use cases. + - [ ] Ensure that services and controllers are wired to use analytics use cases via tokens, not via direct class references. + +- File: apps/api/src/domain/analytics/AnalyticsModule.test.ts + - [ ] Update module-level tests to reflect the new provider wiring, especially the binding of presenters as output ports for use cases. + +- File: apps/api/src/domain/analytics/AnalyticsProviders.ts + - [ ] Ensure all analytics repositories and use cases are exposed via clear token constants and that these tokens are used consistently in service constructors. + - [ ] Add or adjust any tokens required for use case output port injection, without introducing presenter tokens for services. + +### DTOs + +- Files: apps/api/src/domain/analytics/dtos/GetAnalyticsMetricsOutputDTO.ts, GetDashboardDataOutputDTO.ts, RecordEngagementInputDTO.ts, RecordEngagementOutputDTO.ts, RecordPageViewInputDTO.ts, RecordPageViewOutputDTO.ts + - [ ] Verify that all analytics DTOs represent API-level input or response models only and are not used directly inside core use cases. + - [ ] Ensure naming reflects response model terminology where applicable and is consistent with presenters. + +### Presenters + +- Files: apps/api/src/domain/analytics/presenters/GetAnalyticsMetricsPresenter.ts, GetDashboardDataPresenter.ts, RecordEngagementPresenter.ts, RecordPageViewPresenter.ts + - [ ] For each presenter, ensure it implements the use case output port contract for the corresponding analytics result model. + - [ ] Ensure each presenter maintains internal response model state that is constructed from the core result model. + - [ ] Ensure each presenter exposes a getter that returns the response model used by controllers or services. + - [ ] Move any analytics-specific mapping and transformation from services into these presenters. + - [ ] Align terminology within presenters to use response model rather than view model. + +- Files: apps/api/src/domain/analytics/presenters/GetAnalyticsMetricsPresenter.test.ts, GetDashboardDataPresenter.test.ts, RecordEngagementPresenter.test.ts, RecordPageViewPresenter.test.ts + - [ ] Update tests to validate that each presenter receives a core result model, transforms it correctly, and exposes the correct response model. + - [ ] Ensure tests no longer assume mapping occurs in services; all mapping assertions should target presenter behavior. + +--- + +## Auth domain module + +Directory: apps/api/src/domain/auth + +### Controllers + +- File: apps/api/src/domain/auth/AuthController.ts + - [ ] Review all controller methods and ensure they consume response models returned from the auth service, not raw domain objects or DTOs assembled by the controller. + - [ ] Align controller return types with the response models produced by auth presenters. + +- File: apps/api/src/domain/auth/AuthController.test.ts + - [ ] Update tests so they verify the controller’s interaction with the auth service in terms of response models and error handling consistent with use case Results. + +### Services + +- File: apps/api/src/domain/auth/AuthService.ts + - [ ] For signup, login, and logout operations, ensure the service only coordinates input, calls the corresponding core use cases, and retrieves response models from auth presenters. + - [ ] Remove all mapping logic in the service that translates between core user or session representations and API DTOs; move this logic into dedicated presenters. + - [ ] Ensure use cases are injected via tokens and that repositories and ports also use token-based injection. + - [ ] Ensure presenters are not injected into the service via dependency injection and are instead treated as part of the output port wiring and imported where necessary. + - [ ] Ensure each public service method returns a response model based on presenter state, not core domain entities. + +### Module and providers + +- File: apps/api/src/domain/auth/AuthModule.ts + - [ ] Ensure the module declares auth presenters as providers and wires them as implementations of the use case output port for login, signup, and logout use cases. + - [ ] Confirm that the auth service and controller depend on use cases and ports via the defined tokens. + +- File: apps/api/src/domain/auth/AuthModule.test.ts + - [ ] Update module tests to reflect the new wiring of auth presenters as output ports and the absence of presenter injection into services. + +- File: apps/api/src/domain/auth/AuthProviders.ts + - [ ] Verify that all tokens for auth repositories, services, and use cases are defined and consistently used. + - [ ] Add or adjust tokens required for output port injection, ensuring presenters themselves are not injected into services. + +### DTOs + +- File: apps/api/src/domain/auth/dtos/AuthDto.ts + - [ ] Ensure DTOs in this file represent API-level input and response models only and are not referenced by core use cases. + - [ ] Align DTO naming with response model terminology where applicable. + +### Presenters + +- Files: apps/api/src/domain/auth/presenters/AuthSessionPresenter.ts, CommandResultPresenter.ts + - [ ] Ensure each presenter implements the generic use case output port contract for the relevant auth result model. + - [ ] Ensure each presenter maintains internal response model state derived from the core result model. + - [ ] Ensure a getter method is available to expose the response model to controllers and services. + - [ ] Move all auth-related mapping logic from the auth service into these presenters. + - [ ] Normalize terminology within presenters to use response model instead of view model. + +- File: apps/api/src/domain/auth/presenters/AuthSessionPresenter.test.ts + - [ ] Update tests so they validate that the auth session presenter receives core result models from auth use cases and correctly transforms them into auth response models. + +--- + +## Dashboard domain module + +Directory: apps/api/src/domain/dashboard + +### Controllers + +- File: apps/api/src/domain/dashboard/DashboardController.ts + - [ ] Ensure controller methods depend on dashboard service methods that return response models, not core objects or partial mappings. + - [ ] Align method return types and expectations with the dashboard response models built by presenters. + +- File: apps/api/src/domain/dashboard/DashboardController.test.ts + - [ ] Update tests to assert that the controller interacts with the service in terms of response models, not internal mapping behavior. + +### Services + +- File: apps/api/src/domain/dashboard/DashboardService.ts + - [ ] Identify all dashboard service methods that construct or manipulate DTOs directly and move this logic into dashboard presenters. + - [ ] Ensure each service method calls the appropriate dashboard use case, allows it to drive presenters through output ports, and returns a response model obtained from presenters. + - [ ] Confirm that dashboard use cases and repositories are injected via tokens, with no presenters injected via dependency injection. + +### Module and providers + +- File: apps/api/src/domain/dashboard/DashboardModule.ts + - [ ] Ensure the module binds dashboard presenters as output port implementations for the relevant use cases. + - [ ] Ensure dashboard services depend on use cases via tokens only. + +- File: apps/api/src/domain/dashboard/DashboardModule.test.ts + - [ ] Adjust tests to confirm correct provider wiring of presenters as output ports. + +- File: apps/api/src/domain/dashboard/DashboardProviders.ts + - [ ] Review token definitions for repositories, services, and use cases; ensure all are used consistently in constructor injection. + - [ ] Add or adjust any tokens needed for output port wiring. + +### DTOs + +- Files: apps/api/src/domain/dashboard/dtos/DashboardDriverSummaryDTO.ts, DashboardFeedItemSummaryDTO.ts, DashboardRaceSummaryDTO.ts + - [ ] Verify that these DTOs are used only as API-level response models from presenters or services and not within core use cases. + - [ ] Align naming and fields with the response models produced by dashboard presenters. + +### Presenters + +- (Any dashboard presenters, when added or identified in the codebase) + - [ ] Ensure they implement the use case output port contract, maintain internal response model state, and expose response model getters. + - [ ] Move all dashboard mapping and DTO-building logic into these presenters. + +--- + +## Driver domain module + +Directory: apps/api/src/domain/driver + +### Controllers + +- File: apps/api/src/domain/driver/DriverController.ts + - [ ] Ensure controller methods depend on driver service methods that return response models, not domain entities or partial DTOs. + - [ ] Align method signatures and return types with driver response models provided by presenters. + +- File: apps/api/src/domain/driver/DriverController.test.ts + - [ ] Update tests so they verify controller interactions with the driver service via response models and error handling consistent with use case Results. + +### Services + +- File: apps/api/src/domain/driver/DriverService.ts + - [ ] Identify all mapping logic from driver domain objects to DTOs in the service and move that logic into driver presenters. + - [ ] Ensure each service method calls the relevant driver use case, lets the use case present results through presenters, and returns response models obtained from presenters. + - [ ] Confirm that repositories and use cases are injected via tokens, not via direct class references. + - [ ] Ensure no presenter is injected into the driver service via dependency injection. + +### Module and providers + +- File: apps/api/src/domain/driver/DriverModule.ts + - [ ] Ensure driver presenters are registered as providers and are bound as output port implementations for driver use cases. + - [ ] Ensure the driver service and controller depend on use cases via tokens. + +- File: apps/api/src/domain/driver/DriverModule.test.ts + - [ ] Update module tests to reflect the wiring of presenters as output ports and the token-based injection of use cases. + +### DTOs + +- Files: apps/api/src/domain/driver/dtos/CompleteOnboardingInputDTO.ts, CompleteOnboardingOutputDTO.ts, DriverDTO.ts, DriverLeaderboardItemDTO.ts, DriverRegistrationStatusDTO.ts, DriversLeaderboardDTO.ts, DriverStatsDTO.ts, GetDriverOutputDTO.ts, GetDriverProfileOutputDTO.ts, GetDriverRegistrationStatusQueryDTO.ts + - [ ] Ensure these DTOs are used exclusively as API input or response models, and not inside core use cases. + - [ ] Align names and shapes with the response models and input expectations defined in driver presenters and services. + +### Presenters + +- Files: apps/api/src/domain/driver/presenters/CompleteOnboardingPresenter.ts, DriverPresenter.ts, DriverProfilePresenter.ts, DriverRegistrationStatusPresenter.ts, DriversLeaderboardPresenter.ts, DriverStatsPresenter.ts + - [ ] Ensure each presenter implements the use case output port contract for its driver result model. + - [ ] Ensure each presenter maintains an internal response model that is constructed from the driver result model. + - [ ] Ensure each presenter exposes a getter that returns the response model. + - [ ] Move all driver mapping logic from the driver service into the relevant presenters. + - [ ] Consistently use response model terminology within presenters. + +- Files: apps/api/src/domain/driver/presenters/DriverRegistrationStatusPresenter.test.ts, DriversLeaderboardPresenter.test.ts, DriverStatsPresenter.test.ts + - [ ] Update tests to confirm that presenters correctly transform core result models into driver response models and that no mapping remains in the service. + +--- + +## League domain module + +Directory: apps/api/src/domain/league + +### Controllers + +- File: apps/api/src/domain/league/LeagueController.ts + - [ ] Ensure controller methods expect league response models from the league service and do not depend on internal DTO mapping logic. + - [ ] Align return types with the league response models constructed by presenters. + +- File: apps/api/src/domain/league/LeagueController.test.ts + - [ ] Update tests to verify that controllers interact with the league service via response models and handle errors based on use case Results. + +### Services + +- File: apps/api/src/domain/league/LeagueService.ts + - [ ] Identify all mapping and DTO construction logic in the league service and move it into league presenters. + - [ ] Ensure service methods delegate output handling entirely to presenters and only return response models. + - [ ] Ensure repositories and use cases are injected via tokens and that no presenter is injected via dependency injection. + +- File: apps/api/src/domain/league/LeagueService.test.ts + - [ ] Update tests to reflect the reduced responsibility of the league service and to verify that it returns response models produced by presenters. + +### Module and providers + +- (If a LeagueModule file exists in the codebase) + - [ ] Ensure league presenters are registered as providers and bound as output port implementations for league use cases. + +### DTOs + +- Files: apps/api/src/domain/league/dtos/AllLeaguesWithCapacityAndScoringDTO.ts, ApproveLeagueJoinRequestDTO.ts, GetLeagueRacesOutputDTO.ts, GetLeagueWalletOutputDTO.ts, LeagueAdminDTO.ts, LeagueConfigFormModelDropPolicyDTO.ts, LeagueConfigFormModelStewardingDTO.ts, LeagueMemberDTO.ts, LeagueScoringPresetDTO.ts, MembershipStatusDTO.ts, RejectJoinRequestOutputDTO.ts, WithdrawFromLeagueWalletInputDTO.ts, WithdrawFromLeagueWalletOutputDTO.ts + - [ ] Ensure these DTOs are used only as API-level representations and not referenced by core use cases. + - [ ] Align naming with the response models produced by league presenters. + +### Presenters + +- Files: apps/api/src/domain/league/presenters/GetLeagueAdminPermissionsPresenter.ts, LeagueScoringPresetsPresenter.ts + - [ ] Ensure each presenter implements the use case output port contract for its league result model. + - [ ] Ensure each presenter constructs and stores a league response model from the core result model and exposes a getter. + - [ ] Move league mapping logic from the league service into these presenters. + +- File: apps/api/src/domain/league/presenters/LeagueOwnerSummaryPresenter.test.ts + - [ ] Update tests to confirm that the presenter is responsible for mapping from core result model to response model and that no mapping remains in the service. + +--- + +## Media domain module + +Directory: apps/api/src/domain/media + +### Controllers + +- File: apps/api/src/domain/media/MediaController.ts + - [ ] Ensure controller methods depend on media service methods that return response models, not core media objects or partial DTOs. + - [ ] Align the controller return types with media response models produced by presenters. + +- File: apps/api/src/domain/media/MediaController.test.ts + - [ ] Update tests to verify that controllers work with media response models returned from the service. + +### Services + +- File: apps/api/src/domain/media/MediaService.ts + - [ ] Identify all mapping from media domain objects to DTOs and move this logic into media presenters. + - [ ] Ensure each service method calls the relevant media use case, allows it to use presenters via output ports, and returns response models from presenters. + - [ ] Confirm that repositories and use cases are injected via tokens and that no presenter is injected via dependency injection. + +### Module and providers + +- File: apps/api/src/domain/media/MediaModule.ts + - [ ] Ensure media presenters are registered as providers and bound as output port implementations for media use cases. + +- File: apps/api/src/domain/media/MediaModule.test.ts + - [ ] Update tests to reflect the correct provider wiring and output port bindings. + +- File: apps/api/src/domain/media/MediaProviders.ts + - [ ] Review token definitions for repositories and use cases; ensure they are used consistently for constructor injection. + - [ ] Add or adjust tokens required for output port wiring without introducing presenter tokens for services. + +### DTOs + +- Files: apps/api/src/domain/media/dtos/DeleteMediaOutputDTO.ts, GetAvatarOutputDTO.ts, GetMediaOutputDTO.ts, RequestAvatarGenerationInputDTO.ts, RequestAvatarGenerationOutputDTO.ts, UpdateAvatarInputDTO.ts, UpdateAvatarOutputDTO.ts, UploadMediaInputDTO.ts, UploadMediaOutputDTO.ts + - [ ] Ensure these DTOs serve only as API input and response models and are not used directly within core use cases. + - [ ] Align naming and structure with the response models built by media presenters. + +### Presenters + +- Files: apps/api/src/domain/media/presenters/DeleteMediaPresenter.ts, GetAvatarPresenter.ts, GetMediaPresenter.ts, RequestAvatarGenerationPresenter.ts, UpdateAvatarPresenter.ts, UploadMediaPresenter.ts + - [ ] Ensure each presenter implements the use case output port contract for its media result model. + - [ ] Ensure each presenter maintains internal response model state derived from the core result model and exposes a getter. + - [ ] Move all mapping and response model construction from the media service into these presenters. + +### Types + +- Files: apps/api/src/domain/media/types/FacePhotoData.ts, SuitColor.ts + - [ ] Verify that these types are used appropriately as part of input or response models and not as replacements for core domain entities inside use cases. + +--- + +## Payments domain module + +Directory: apps/api/src/domain/payments + +### Controllers + +- File: apps/api/src/domain/payments/PaymentsController.ts + - [ ] Ensure controller methods call payments use cases via services or directly, receive results that are presented via presenters, and return payments response models. + - [ ] Remove any mapping logic from the controller and rely exclusively on presenters for transforming result models into response models. + +### Module and providers + +- File: apps/api/src/domain/payments/PaymentsModule.ts + - [ ] Ensure payments presenters are registered as providers and bound as output port implementations for payments use cases. + +- File: apps/api/src/domain/payments/PaymentsModule.test.ts + - [ ] Update module tests to reflect correct output port wiring and token-based use case injection. + +- File: apps/api/src/domain/payments/PaymentsProviders.ts + - [ ] Review token definitions for payments repositories and use cases; ensure they are consistently used for dependency injection. + - [ ] Add or adjust tokens as needed for output port wiring. + +### DTOs + +- Files: apps/api/src/domain/payments/dtos/CreatePaymentInputDTO.ts, CreatePaymentOutputDTO.ts, MemberPaymentStatus.ts, MembershipFeeType.ts, PayerType.ts, PaymentDTO.ts, PaymentsDto.ts, PaymentStatus.ts, PaymentType.ts, PrizeType.ts, ReferenceType.ts, TransactionType.ts, UpdatePaymentStatusInputDTO.ts, UpdatePaymentStatusOutputDTO.ts + - [ ] Ensure these DTOs are used solely as API-level input and response models and not within core payments use cases. + - [ ] Align naming and structure with the response models and inputs expected by payments presenters and services. + +### Presenters + +- (Any payments presenters, once identified or added during implementation) + - [ ] Ensure they implement the use case output port contract, maintain internal response model state, and expose getters for response models. + - [ ] Centralize all payments mapping logic in these presenters. + +--- + +## Protests domain module + +Directory: apps/api/src/domain/protests + +### Controllers + +- File: apps/api/src/domain/protests/ProtestsController.ts + - [ ] Ensure controller methods rely on protests service methods that return response models, avoiding any direct mapping from domain objects. + - [ ] Align controller return types with protests response models produced by presenters. + +### Services + +- File: apps/api/src/domain/protests/ProtestsService.ts + - [ ] Identify all mapping logic in the protests service and move it into protests presenters. + - [ ] Ensure each service method calls the appropriate protests use case, lets it use presenters through output ports, and returns response models from presenters. + - [ ] Confirm that protests repositories and use cases are injected via tokens only. + - [ ] Ensure no protests presenter is injected into the service via dependency injection. + +- File: apps/api/src/domain/protests/ProtestsService.test.ts + - [ ] Update tests to reflect the reduced responsibility of the protests service and its reliance on presenters for response model creation. + +### Module and providers + +- File: apps/api/src/domain/protests/ProtestsModule.ts + - [ ] Ensure protests presenters are registered as providers and bound as output port implementations for protests use cases. + +- File: apps/api/src/domain/protests/ProtestsProviders.ts + - [ ] Review token definitions and usage for protests repositories and use cases and ensure consistent usage for injection. + - [ ] Add or adjust tokens for output port wiring. + +### Presenters + +- (Any protests presenters to be added or identified during implementation) + - [ ] Ensure they implement the use case output port contract, maintain internal response model state, and expose getters for response models. + +--- + +## Race domain module + +Directory: apps/api/src/domain/race + +### Controllers + +- File: apps/api/src/domain/race/RaceController.ts + - [ ] Ensure controller methods call race service methods that return response models and do not perform any mapping from race domain entities. + - [ ] Adjust controller return types to reflect race response models created by presenters. + +- File: apps/api/src/domain/race/RaceController.test.ts + - [ ] Update tests so they verify controller behavior in terms of response models and error handling based on use case Results. + +### Services + +- File: apps/api/src/domain/race/RaceService.ts + - [ ] Identify all mapping logic from race domain entities to DTOs and move it into race presenters. + - [ ] Ensure each service method calls the relevant race use case, lets it present through race presenters, and returns response models from presenters. + - [ ] Confirm race repositories and use cases are injected via tokens only and that no presenter is injected via dependency injection. + +- File: apps/api/src/domain/race/RaceService.test.ts + - [ ] Update tests to reflect that the race service now delegates mapping to presenters and returns response models. + +### Module and providers + +- File: apps/api/src/domain/race/RaceModule.ts + - [ ] Ensure race presenters are registered as providers and bound as output port implementations for race use cases. + +- File: apps/api/src/domain/race/RaceModule.test.ts + - [ ] Update tests to confirm correct wiring of race presenters and token-based use case injection. + +- File: apps/api/src/domain/race/RaceProviders.ts + - [ ] Verify token definitions for race repositories and use cases and ensure consistent usage. + - [ ] Add or adjust tokens to support output port wiring. + +### DTOs + +- Files: apps/api/src/domain/race/dtos/AllRacesPageDTO.ts, DashboardDriverSummaryDTO.ts, DashboardFeedSummaryDTO.ts, DashboardFriendSummaryDTO.ts, DashboardLeagueStandingSummaryDTO.ts, DashboardOverviewDTO.ts, DashboardRaceSummaryDTO.ts, DashboardRecentResultDTO.ts, FileProtestCommandDTO.ts, GetRaceDetailParamsDTO.ts, ImportRaceResultsDTO.ts, ImportRaceResultsSummaryDTO.ts, QuickPenaltyCommandDTO.ts, RaceActionParamsDTO.ts, RaceDetailDTO.ts, RaceDetailEntryDTO.ts, RaceDetailLeagueDTO.ts, RaceDetailRaceDTO.ts, RaceDetailRegistrationDTO.ts, RaceDetailUserResultDTO.ts, RacePenaltiesDTO.ts, RacePenaltyDTO.ts, RaceProtestDTO.ts, RaceProtestsDTO.ts, RaceResultDTO.ts, RaceResultsDetailDTO.ts, RacesPageDataDTO.ts, RacesPageDataRaceDTO.ts, RaceStatsDTO.ts, RaceWithSOFDTO.ts, RegisterForRaceParamsDTO.ts, RequestProtestDefenseCommandDTO.ts, ReviewProtestCommandDTO.ts, WithdrawFromRaceParamsDTO.ts + - [ ] Ensure these DTOs serve exclusively as API-level input and response models and are not used directly by core race use cases. + - [ ] Align naming and structures with race response models produced by presenters. + +### Presenters + +- Files: apps/api/src/domain/race/presenters/AllRacesPageDataPresenter.ts, GetAllRacesPresenter.ts, GetTotalRacesPresenter.ts, ImportRaceResultsApiPresenter.ts, RaceDetailPresenter.ts, RacePenaltiesPresenter.ts, RaceProtestsPresenter.ts, RaceWithSOFPresenter.ts + - [ ] Ensure each race presenter implements the use case output port contract for its race result model. + - [ ] Ensure each presenter maintains internal response model state derived from core race result models and exposes getters. + - [ ] Move all race mapping logic and DTO construction from the race service into these presenters. + - [ ] Use response model terminology consistently within presenters. + +- File: apps/api/src/domain/race/presenters/GetAllRacesPresenter.test.ts + - [ ] Update tests so they validate presenter-based mapping from core race result models to race response models and reflect the absence of mapping logic in the service. + +--- + +## Sponsor domain module + +Directory: apps/api/src/domain/sponsor + +### Controllers + +- File: apps/api/src/domain/sponsor/SponsorController.ts + - [ ] Ensure controller methods depend on sponsor service methods that return sponsor response models and avoid direct mapping. + - [ ] Align controller return types with sponsor response models constructed by presenters. + +### Services + +- File: apps/api/src/domain/sponsor/SponsorService.ts + - [ ] Identify and move all sponsor mapping and DTO construction from the service into sponsor presenters. + - [ ] Ensure service methods call sponsor use cases, allow presenters to handle output via output ports, and return response models from presenters. + - [ ] Confirm sponsor repositories and use cases are injected via tokens only, with no presenter injection. + +- File: apps/api/src/domain/sponsor/SponsorService.test.ts + - [ ] Update tests to reflect that sponsor services return response models created by presenters and no longer perform mapping. + +### Module and providers + +- File: apps/api/src/domain/sponsor/SponsorModule.ts + - [ ] Ensure sponsor presenters are registered as providers and bound as output port implementations for sponsor use cases. + +- File: apps/api/src/domain/sponsor/SponsorProviders.ts + - [ ] Review token definitions and usage for sponsor repositories and use cases and adjust as needed for output port wiring. + +### Presenters + +- (Any sponsor presenters present or added during implementation) + - [ ] Ensure they implement the use case output port contract, maintain internal response model state, and expose getters for response models. + +--- + +## Team domain module + +Directory: apps/api/src/domain/team + +### DTOs + +- Files: apps/api/src/domain/team/dtos/GetTeamDetailsOutputDTO.ts, UpdateTeamOutputDTO.ts + - [ ] Ensure these DTOs are used exclusively as API response models and are not referenced directly by core team use cases. + - [ ] Align naming and fields with response models created by team presenters. + +### Presenters + +- Files: apps/api/src/domain/team/presenters/AllTeamsPresenter.ts, CreateTeamPresenter.ts, DriverTeamPresenter.ts, TeamDetailsPresenter.ts + - [ ] Ensure each team presenter implements the use case output port contract for its corresponding team result model. + - [ ] Ensure each presenter maintains internal response model state created from core team result models and exposes response model getters. + - [ ] Move any team mapping or DTO-construction logic from any related services or controllers into these presenters. + +### Services and controllers (if located elsewhere) + +- (Any services or controllers that use the team presenters found in other modules) + - [ ] Ensure these services and controllers treat team presenters as output ports, do not inject presenters directly, and return only response models from presenters. + +--- + +## Final global verification + +- [ ] Run project-wide type checking and resolve any remaining type errors related to use case output ports, presenters, and response models. +- [ ] Run project-wide linting and fix all issues related to unused imports, incorrect injection tokens, or outdated DTO usage. +- [ ] Run the full test suite and ensure that all module tests pass after the migration. +- [ ] Perform a final review of the API layer to confirm that all mapping from domain objects to API representations is performed by presenters, that services are orchestration-only, and that use cases present via output ports and return Result types without performing serialization. \ No newline at end of file