This commit is contained in:
2025-12-21 19:53:22 +01:00
parent f2d8a23583
commit 3c64f328e2
105 changed files with 3191 additions and 1706 deletions

View File

@@ -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!' });
});
});

View File

@@ -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;
}
}

View File

@@ -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<string, Error>): 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');
}

View File

@@ -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<typeof vi.mocked<Response>> = {
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<typeof vi.mocked<Response>> = {
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);
});
});
});

View File

@@ -27,8 +27,8 @@ export class AnalyticsController {
@Body() input: RecordPageViewInput,
@Res() res: Response,
): Promise<void> {
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<void> {
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<GetDashboardDataOutputDTO> {
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<GetAnalyticsMetricsOutputDTO> {
const presenter = await this.analyticsService.getAnalyticsMetrics();
return presenter.viewModel;
return this.analyticsService.getAnalyticsMetrics();
}
}

View File

@@ -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<RecordPageViewOutput>) =>
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<RecordEngagementOutput>) =>
new RecordEngagementUseCase(repo, logger, output),
inject: [IENGAGEMENT_REPO_TOKEN, Logger_TOKEN, RECORD_ENGAGEMENT_OUTPUT_PORT_TOKEN],
},
{
provide: GetDashboardDataUseCase,
useFactory: (logger: Logger, output: UseCaseOutputPort<GetDashboardDataOutput>) =>
new GetDashboardDataUseCase(logger, output),
inject: [Logger_TOKEN, GET_DASHBOARD_DATA_OUTPUT_PORT_TOKEN],
},
{
provide: GetAnalyticsMetricsUseCase,
useFactory: (repo: IPageViewRepository, logger: Logger, output: UseCaseOutputPort<GetAnalyticsMetricsOutput>) =>
new GetAnalyticsMetricsUseCase(repo, logger, output),
inject: [IPAGE_VIEW_REPO_TOKEN, Logger_TOKEN, GET_ANALYTICS_METRICS_OUTPUT_PORT_TOKEN],
},
];

View File

@@ -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<RecordPageViewPresenter> {
const presenter = new RecordPageViewPresenter();
await this.recordPageViewUseCase.execute(input, presenter);
return presenter;
async recordPageView(input: RecordPageViewInput): Promise<RecordPageViewOutputDTO> {
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<RecordEngagementPresenter> {
const presenter = new RecordEngagementPresenter();
await this.recordEngagementUseCase.execute(input, presenter);
return presenter;
async recordEngagement(input: RecordEngagementInput): Promise<RecordEngagementOutputDTO> {
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<GetDashboardDataPresenter> {
const presenter = new GetDashboardDataPresenter();
await this.getDashboardDataUseCase.execute(undefined, presenter);
return presenter;
async getDashboardData(): Promise<GetDashboardDataOutputDTO> {
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<GetAnalyticsMetricsPresenter> {
const presenter = new GetAnalyticsMetricsPresenter();
await this.getAnalyticsMetricsUseCase.execute(undefined, presenter);
return presenter;
async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsOutputDTO> {
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();
}
}

View File

@@ -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');
});
});

View File

@@ -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<GetAnalyticsMetricsOutput> {
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;
}
}

View File

@@ -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');
});
});

View File

@@ -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<GetDashboardDataOutput> {
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;
}
}

View File

@@ -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');
});
});

View File

@@ -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<RecordEngagementOutput> {
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;
}
}

View File

@@ -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');
});
});

View File

@@ -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<RecordPageViewOutput> {
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;
}
}

View File

@@ -5,21 +5,21 @@ import { SignupParams, LoginParams, AuthSessionDTO } from './dtos/AuthDto';
describe('AuthController', () => {
let controller: AuthController;
let service: ReturnType<typeof vi.mocked<AuthService>>;
let service: AuthService;
beforeEach(() => {
service = vi.mocked<AuthService>({
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);
});
});
});

View File

@@ -8,26 +8,21 @@ export class AuthController {
@Post('signup')
async signup(@Body() params: SignupParams): Promise<AuthSessionDTO> {
const presenter = await this.authService.signupWithEmail(params);
return presenter.viewModel;
return this.authService.signupWithEmail(params);
}
@Post('login')
async login(@Body() params: LoginParams): Promise<AuthSessionDTO> {
const presenter = await this.authService.loginWithEmail(params);
return presenter.viewModel;
return this.authService.loginWithEmail(params);
}
@Get('session')
async getSession(): Promise<AuthSessionDTO | null> {
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();
}
}

View File

@@ -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],
},
];

View File

@@ -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<AuthSessionPresenter | null> {
async getCurrentSession(): Promise<AuthSessionDTO | null> {
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<AuthSessionPresenter> {
async signupWithEmail(params: SignupParams): Promise<AuthSessionDTO> {
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<AuthSessionPresenter> {
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<AuthSessionDTO> {
this.logger.debug(`[AuthService] Attempting login for email: ${params.email}`);
async logout(): Promise<CommandResultPresenter> {
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<CommandResultDTO> {
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();
}
}

View File

@@ -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');
});
});

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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', () => {

View File

@@ -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<DashboardOverviewDTO> {
const presenter = await this.dashboardService.getDashboardOverview(driverId);
return presenter.viewModel;
return this.dashboardService.getDashboardOverview(driverId);
}
}

View File

@@ -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<DashboardOverviewResult>,
) =>
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,
],
},
];

View File

@@ -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<DashboardOverviewPresenter> {
async getDashboardOverview(driverId: string): Promise<DashboardOverviewDTO> {
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();
}
}

View File

@@ -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 {

View File

@@ -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);
});
});

View File

@@ -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<DashboardOverviewResult> {
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;
}
}

View File

@@ -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';

View File

@@ -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<DriversLeaderboardPresenter> {
async getDriversLeaderboard(): Promise<DriversLeaderboardDTO> {
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<DriverStatsPresenter> {
async getTotalDrivers(): Promise<DriverStatsDTO> {
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<CompleteOnboardingPresenter> {
async completeOnboarding(
userId: string,
input: CompleteOnboardingInputDTO,
): Promise<CompleteOnboardingOutputDTO> {
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<DriverRegistrationStatusPresenter> {
): Promise<DriverRegistrationStatusDTO> {
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<DriverPresenter> {
async getCurrentDriver(userId: string): Promise<GetDriverOutputDTO | null> {
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<DriverPresenter> {
): Promise<GetDriverOutputDTO | null> {
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<DriverPresenter> {
async getDriver(driverId: string): Promise<GetDriverOutputDTO | null> {
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<DriverProfilePresenter> {
async getDriverProfile(driverId: string): Promise<GetDriverProfileOutputDTO> {
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;
}
}

View File

@@ -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<CompleteDriverOnboardingResult>
{
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;
}
}

View File

@@ -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;
}
}

View File

@@ -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<GetProfileOverviewResult>
{
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;
}
}

View File

@@ -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<IsDriverRegisteredForRaceResult>
{
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;
}
}

View File

@@ -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<GetTotalDriversResult, never>(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<GetTotalDriversResult, never>(output);
presenter.present(result);
expect(presenter.responseModel).toBeDefined();
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
expect(() => presenter.responseModel).toThrow('Presenter not presented');
});
});
});

View File

@@ -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<GetTotalDriversResult>
{
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;
}
}

View File

@@ -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<GetDriversLeaderboardResult, never>(coreResult);
const result = presenter.viewModel;
presenter.present(result);
const api = presenter.responseModel;
expect(result.drivers).toHaveLength(2);
expect(result.drivers[0]).toEqual({

View File

@@ -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<GetDriversLeaderboardResult, DriversLeaderboardApplicationError>,
): 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;
}
}

View File

@@ -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,

View File

@@ -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<TotalLeaguesDTO> {

View File

@@ -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<GetAllLeaguesWithCapacityResult, 'REPOSITORY_ERROR'> {
present(result: Result<GetAllLeaguesWithCapacityResult, ApplicationErrorCode<'REPOSITORY_ERROR'>>): 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;
}
}

View File

@@ -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<typeof vi.mocked<MediaService>>;
let service: jest.Mocked<MediaService>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -29,153 +35,201 @@ describe('MediaController', () => {
}).compile();
controller = module.get<MediaController>(MediaController);
service = vi.mocked(module.get(MediaService));
service = module.get(MediaService) as jest.Mocked<MediaService>;
});
const createMockResponse = (): Response => {
const res: Partial<Response> = {};
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<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
} as unknown as ReturnType<typeof vi.mocked<Response>>;
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<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
} as unknown as ReturnType<typeof vi.mocked<Response>>;
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<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
} as unknown as ReturnType<typeof vi.mocked<Response>>;
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<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
} as unknown as ReturnType<typeof vi.mocked<Response>>;
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<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
} as unknown as ReturnType<typeof vi.mocked<Response>>;
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<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
} as unknown as ReturnType<typeof vi.mocked<Response>>;
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<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
} as unknown as ReturnType<typeof vi.mocked<Response>>;
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<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
} as unknown as ReturnType<typeof vi.mocked<Response>>;
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);
});
});
});
});

View File

@@ -29,13 +29,12 @@ export class MediaController {
@Body() input: RequestAvatarGenerationInput,
@Res() res: Response,
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}

View File

@@ -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<RequestAvatarGenerationPresenter> {
async requestAvatarGeneration(
input: RequestAvatarGenerationInput,
): Promise<RequestAvatarGenerationOutputDTO> {
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<UploadMediaPresenter> {
async uploadMedia(
input: UploadMediaInput & { file: Express.Multer.File } & { userId?: string; metadata?: Record<string, any> },
): Promise<UploadMediaOutputDTO> {
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<GetMediaPresenter> {
async getMedia(mediaId: string): Promise<GetMediaOutputDTO | null> {
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<DeleteMediaPresenter> {
async deleteMedia(mediaId: string): Promise<DeleteMediaOutputDTO> {
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<GetAvatarPresenter> {
async getAvatar(driverId: string): Promise<GetAvatarOutputDTO | null> {
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<UpdateAvatarPresenter> {
async updateAvatar(driverId: string, input: UpdateAvatarInput): Promise<UpdateAvatarOutputDTO> {
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;
}
}

View File

@@ -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<DeleteMediaResult, DeleteMediaApplicationError>): 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,
};
}
}
getResponseModel(): DeleteMediaResponseModel | null {
return this.model;
}
get responseModel(): DeleteMediaResponseModel {
if (!this.model) throw new Error('Presenter not presented');
return this.model;
}
}

View File

@@ -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<GetAvatarResult, GetAvatarApplicationError>): 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;
}
}

View File

@@ -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<GetMediaResult, GetMediaApplicationError>): 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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<UpdateAvatarResult, UpdateAvatarApplicationError>): 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;
}
}

View File

@@ -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<UploadMediaResult, UploadMediaApplicationError>): 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,
};
}
}
getResponseModel(): UploadMediaResponseModel | null {
return this.model;
}
get responseModel(): UploadMediaResponseModel {
if (!this.model) throw new Error('Presenter not presented');
return this.model;
}
}

View File

@@ -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;
}
}

View File

@@ -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',

View File

@@ -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<ReviewProtestCommandDTO, 'protestId'>,
): Promise<void> {
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');
}
}
}

View File

@@ -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<void, never>(undefined));
executeMock.mockResolvedValue(Result.ok<ReviewProtestResult, ReviewProtestApplicationError>(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<ReviewProtestResponseDTO>({
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<ReviewProtestResult, ReviewProtestApplicationError>(error));
expect(viewModel).toEqual({
const dto = await service.reviewProtest(baseCommand);
expect(dto).toEqual<ReviewProtestResponseDTO>({
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<ReviewProtestResult, ReviewProtestApplicationError>(error));
expect(viewModel).toEqual({
const dto = await service.reviewProtest(baseCommand);
expect(dto).toEqual<ReviewProtestResponseDTO>({
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<ReviewProtestResult, ReviewProtestApplicationError>(error));
expect(viewModel).toEqual({
const dto = await service.reviewProtest(baseCommand);
expect(dto).toEqual<ReviewProtestResponseDTO>({
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<ReviewProtestResult, ReviewProtestApplicationError>(error));
expect(viewModel).toEqual({
const dto = await service.reviewProtest(baseCommand);
expect(dto).toEqual<ReviewProtestResponseDTO>({
success: false,
errorCode: 'UNEXPECTED',
message: 'Failed to review protest',

View File

@@ -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<ReviewProtestPresenter> {
}): Promise<ReviewProtestResponseDTO> {
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;
}
}

View File

@@ -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<ReviewProtestResult, ReviewProtestApplicationError>,
): 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;
}
}

View File

@@ -96,25 +96,20 @@ export class RaceService {
async getAllRaces(): Promise<GetAllRacesPresenter> {
this.logger.debug('[RaceService] Fetching all races.');
const result = await this.getAllRacesUseCase.execute();
if (result.isErr()) {
throw new Error('Failed to get all races');
}
const result = await this.getAllRacesUseCase.execute({});
const presenter = new GetAllRacesPresenter();
await presenter.present(result.unwrap());
presenter.reset();
presenter.present(result);
return presenter;
}
async getTotalRaces(): Promise<GetTotalRacesPresenter> {
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;
}

View File

@@ -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<GetAllRacesPageDataResult, GetAllRacesPageDataApplicationError>,
): 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;
}
}

View File

@@ -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<E extends string = string> = ApplicationErrorCode<
E,
{ message: string }
>;
export class CommandResultPresenter<E extends string = string> {
private model: CommandResultDTO | null = null;
reset(): void {
this.model = null;
}
present(result: Result<unknown, CommandApplicationError<E>>): 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;
}
}

View File

@@ -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 () => {

View File

@@ -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<string, { id: string; name: string }>();
for (const race of output.races) {
uniqueLeagues.set(race.leagueId, {
id: race.leagueId,
name: race.leagueName,
});
present(result: Result<GetAllRacesResult, GetAllRacesApplicationError>): 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<string, string>();
const uniqueLeagues = new Map<string, { id: string; name: string }>();
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;
}
}

View File

@@ -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<GetTotalRacesResult, GetTotalRacesApplicationError>): 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;
}
}

View File

@@ -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<ImportRaceResultsApiResult, ImportRaceResultsApiApplicationError>,
): 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;
}
}

View File

@@ -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<void> {
const raceDTO: RaceDetailRaceDTO | null = outputPort.race
reset(): void {
this.model = null;
}
async present(
result: Result<GetRaceDetailResult, GetRaceDetailApplicationError>,
params: GetRaceDetailParamsDTO,
): Promise<void> {
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 {

View File

@@ -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<GetRacePenaltiesResult, GetRacePenaltiesApplicationError>): 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<string, string> = {};
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;
}
}

View File

@@ -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<GetRaceProtestsResult, GetRaceProtestsApplicationError>): 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<string, string> = {};
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;
}
}

View File

@@ -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<void> {
const driverMap = new Map(outputPort.drivers.map(driver => [driver.id, driver]));
reset(): void {
this.model = null;
}
async present(
result: Result<GetRaceResultsDetailResult, GetRaceResultsDetailApplicationError>,
): Promise<void> {
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;
}
}

View File

@@ -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<GetRaceWithSOFResult, GetRaceWithSOFApplicationError>): 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;
}
}

View File

@@ -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<void> {
const allLeagues = await this.leagueRepository.findAll();
const leagueMap = new Map(allLeagues.map(l => [l.id, l.name]));
present(
result: Result<GetRacesPageDataResult, GetRacesPageDataApplicationError>,
): 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;
}
}

View File

@@ -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],
},
{

View File

@@ -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<GetSponsorsPresenter> {
async getSponsors(): Promise<GetSponsorsOutputDTO> {
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<CreateSponsorPresenter> {
@@ -264,92 +263,18 @@ export class SponsorService {
return presenter;
}
async getSponsorBilling(sponsorId: string): Promise<SponsorBillingPresenter> {
async getSponsorBilling(sponsorId: string): Promise<GetSponsorBillingPresenter> {
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;
}

View File

@@ -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<GetSponsorsResult, never>({ 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<GetSponsorsResult, never>({
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<GetSponsorsResult, never>({ 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<GetSponsorsErrorCode, { message: string }>;
const result = Result.err<GetSponsorsResult, typeof error>(error);
expect(presenter.viewModel).toEqual(mockResult);
presenter.present(result);
expect(presenter.responseModel).toEqual({ sponsors: [] });
});
});
});

View File

@@ -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<GetSponsorsErrorCode, { message: string }>
>,
): 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<SponsorDTO>((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;
}
}

View File

@@ -23,7 +23,7 @@ export class TeamController {
@ApiResponse({ status: 200, description: 'List of all teams', type: GetAllTeamsOutputDTO })
async getAll(): Promise<GetAllTeamsOutputDTO> {
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<GetTeamDetailsOutputDTO | null> {
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<GetTeamMembersOutputDTO> {
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<GetTeamJoinRequestsOutputDTO> {
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<CreateTeamOutputDTO> {
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<UpdateTeamOutputDTO> {
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<GetDriverTeamOutputDTO | null> {
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<GetTeamMembershipOutputDTO | null> {
const presenter = await this.teamService.getMembership(teamId, driverId);
return presenter.viewModel;
return presenter.responseModel;
}
}

View File

@@ -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<AllTeamsPresenter> {
async getAll(): Promise<any> { // 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<TeamDetailsPresenter> {

View File

@@ -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<GetAllTeamsErrorCode, { message: string }>;
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<GetAllTeamsResult, GetAllTeamsError>): 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;
}
}

View File

@@ -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<CreateTeamErrorCode, { message: string }>;
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<CreateTeamResult, CreateTeamError>): 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;
}
}

View File

@@ -10,6 +10,6 @@ export class HelloController {
@Get()
getHello(): { message: string } {
const presenter = this.helloService.getHello();
return presenter.viewModel;
return presenter.responseModel;
}
}

View File

@@ -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<CreatePaymentResult> {
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,
};
}
}

View File

@@ -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<GetPaymentsResult> {
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,
};
}
}

View File

@@ -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<GetSponsorBillingResult> {
private viewModel: SponsorBillingSummary | null = null;
present(result: GetSponsorBillingResult): void {
this.viewModel = result;
}
getViewModel(): SponsorBillingSummary | null {
return this.viewModel;
}
reset(): void {
this.viewModel = null;
}
}

View File

@@ -0,0 +1,4 @@
export * from './types';
export * from './CreatePaymentPresenter';
export * from './GetPaymentsPresenter';
export * from './GetSponsorBillingPresenter';

View File

@@ -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;
}

View File

@@ -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<Result<any, any>> & { present: Mock };
let output: UseCaseOutputPort<GetAnalyticsMetricsOutput> & { 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();
});
});

View File

@@ -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<GetAnalyticsMetricsInput, GetAnalyticsMetricsOutput, GetAnalyticsMetricsErrorCode> {
export class GetAnalyticsMetricsUseCase implements UseCase<GetAnalyticsMetricsInput, void, GetAnalyticsMetricsErrorCode> {
constructor(
private readonly pageViewRepository: IPageViewRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<GetAnalyticsMetricsOutput>,
) {}
async execute(input: GetAnalyticsMetricsInput = {}): Promise<Result<GetAnalyticsMetricsOutput, ApplicationErrorCode<GetAnalyticsMetricsErrorCode, { message: string }>>> {
async execute(input: GetAnalyticsMetricsInput = {}): Promise<Result<void, ApplicationErrorCode<GetAnalyticsMetricsErrorCode, { message: string }>>> {
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<GetAnalyticsMetricsIn
uniqueVisitors,
});
const result = Result.ok<GetAnalyticsMetricsOutput, ApplicationErrorCode<GetAnalyticsMetricsErrorCode, { message: string }>>({
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<GetAnalyticsMetricsOutput, ApplicationErrorCode<GetAnalyticsMetricsErrorCode, { message: string }>>({
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: err.message ?? 'Failed to get analytics metrics' },
});
return result;
}
}
}

View File

@@ -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<Result<any, any>> & { present: Mock };
let output: UseCaseOutputPort<GetDashboardDataOutput> & { 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();
});

View File

@@ -13,12 +13,13 @@ export interface GetDashboardDataOutput {
export type GetDashboardDataErrorCode = 'REPOSITORY_ERROR';
export class GetDashboardDataUseCase implements UseCase<GetDashboardDataInput, GetDashboardDataOutput, GetDashboardDataErrorCode> {
export class GetDashboardDataUseCase implements UseCase<GetDashboardDataInput, void, GetDashboardDataErrorCode> {
constructor(
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<GetDashboardDataOutput>,
) {}
async execute(input: GetDashboardDataInput = {}): Promise<Result<GetDashboardDataOutput, ApplicationErrorCode<GetDashboardDataErrorCode, { message: string }>>> {
async execute(input: GetDashboardDataInput = {}): Promise<Result<void, ApplicationErrorCode<GetDashboardDataErrorCode, { message: string }>>> {
try {
// Placeholder implementation - would need repositories from identity and racing domains
const totalUsers = 0;
@@ -26,6 +27,15 @@ export class GetDashboardDataUseCase implements UseCase<GetDashboardDataInput, G
const totalRaces = 0;
const totalLeagues = 0;
const resultModel: GetDashboardDataOutput = {
totalUsers,
activeUsers,
totalRaces,
totalLeagues,
};
this.output.present(resultModel);
this.logger.info('Dashboard data retrieved', {
totalUsers,
activeUsers,
@@ -33,21 +43,14 @@ export class GetDashboardDataUseCase implements UseCase<GetDashboardDataInput, G
totalLeagues,
});
const result = Result.ok<GetDashboardDataOutput, ApplicationErrorCode<GetDashboardDataErrorCode, { message: string }>>({
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<GetDashboardDataOutput, ApplicationErrorCode<GetDashboardDataErrorCode, { message: string }>>({
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: err.message ?? 'Failed to get dashboard data' },
});
return result;
}
}
}

View File

@@ -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();
});
});

View File

@@ -52,7 +52,6 @@ export class GetEntityAnalyticsQuery
private readonly pageViewRepository: IPageViewRepository,
private readonly engagementRepository: IEngagementRepository,
private readonly snapshotRepository: IAnalyticsSnapshotRepository,
private readonly output: UseCaseOutputPort<Result<EntityAnalyticsOutput, ApplicationErrorCode<GetEntityAnalyticsErrorCode, { message: string }>>>,
private readonly logger: Logger
) {}
@@ -145,10 +144,8 @@ export class GetEntityAnalyticsQuery
label: this.formatPeriodLabel(since, now),
},
};
const result = Result.ok<EntityAnalyticsOutput, ApplicationErrorCode<GetEntityAnalyticsErrorCode, { message: string }>>(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;
}
}

View File

@@ -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<Result<any, any>> & { present: Mock };
let output: UseCaseOutputPort<RecordEngagementOutput> & { 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();
});
});

View File

@@ -22,13 +22,14 @@ export interface RecordEngagementOutput {
export type RecordEngagementErrorCode = 'REPOSITORY_ERROR';
export class RecordEngagementUseCase implements UseCase<RecordEngagementInput, RecordEngagementOutput, RecordEngagementErrorCode> {
export class RecordEngagementUseCase implements UseCase<RecordEngagementInput, void, RecordEngagementErrorCode> {
constructor(
private readonly engagementRepository: IEngagementRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<RecordEngagementOutput>,
) {}
async execute(input: RecordEngagementInput): Promise<Result<RecordEngagementOutput, ApplicationErrorCode<RecordEngagementErrorCode, { message: string }>>> {
async execute(input: RecordEngagementInput): Promise<Result<void, ApplicationErrorCode<RecordEngagementErrorCode, { message: string }>>> {
try {
const engagementEvent = EngagementEvent.create({
id: crypto.randomUUID(),
@@ -43,6 +44,13 @@ export class RecordEngagementUseCase implements UseCase<RecordEngagementInput, R
await this.engagementRepository.save(engagementEvent);
const resultModel: RecordEngagementOutput = {
eventId: engagementEvent.id,
engagementWeight: engagementEvent.getEngagementWeight(),
};
this.output.present(resultModel);
this.logger.info('Engagement event recorded', {
engagementId: engagementEvent.id,
action: input.action,
@@ -50,19 +58,14 @@ export class RecordEngagementUseCase implements UseCase<RecordEngagementInput, R
entityType: input.entityType,
});
const result = Result.ok<RecordEngagementOutput, ApplicationErrorCode<RecordEngagementErrorCode, { message: string }>>({
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<RecordEngagementOutput, ApplicationErrorCode<RecordEngagementErrorCode, { message: string }>>({
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: err.message ?? 'Failed to record engagement event' },
});
return result;
}
}
}

View File

@@ -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<Result<any, any>> & { present: Mock };
let output: UseCaseOutputPort<RecordPageViewOutput> & { 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();
});
});

View File

@@ -22,13 +22,14 @@ export interface RecordPageViewOutput {
export type RecordPageViewErrorCode = 'REPOSITORY_ERROR';
export class RecordPageViewUseCase implements UseCase<RecordPageViewInput, RecordPageViewOutput, RecordPageViewErrorCode> {
export class RecordPageViewUseCase implements UseCase<RecordPageViewInput, void, RecordPageViewErrorCode> {
constructor(
private readonly pageViewRepository: IPageViewRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<RecordPageViewOutput>,
) {}
async execute(input: RecordPageViewInput): Promise<Result<RecordPageViewOutput, ApplicationErrorCode<RecordPageViewErrorCode, { message: string }>>> {
async execute(input: RecordPageViewInput): Promise<Result<void, ApplicationErrorCode<RecordPageViewErrorCode, { message: string }>>> {
try {
const pageView = PageView.create({
id: crypto.randomUUID(),
@@ -44,24 +45,26 @@ export class RecordPageViewUseCase implements UseCase<RecordPageViewInput, Recor
await this.pageViewRepository.save(pageView);
const resultModel: RecordPageViewOutput = {
pageViewId: pageView.id,
};
this.output.present(resultModel);
this.logger.info('Page view recorded', {
pageViewId: pageView.id,
entityId: input.entityId,
entityType: input.entityType,
});
const result = Result.ok<RecordPageViewOutput, ApplicationErrorCode<RecordPageViewErrorCode, { message: string }>>({
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<RecordPageViewOutput, ApplicationErrorCode<RecordPageViewErrorCode, { message: string }>>({
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: err.message ?? 'Failed to record page view' },
});
return result;
}
}
}

View File

@@ -24,43 +24,38 @@ export type LoginApplicationError = ApplicationErrorCode<LoginErrorCode, { messa
*
* Handles user login by verifying credentials.
*/
export class LoginUseCase implements UseCase<LoginInput, LoginResult, LoginErrorCode> {
export class LoginUseCase implements UseCase<LoginInput, void, LoginErrorCode> {
constructor(
private readonly authRepo: IAuthRepository,
private readonly passwordService: IPasswordHashingService,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<Result<LoginResult, LoginApplicationError>>,
private readonly output: UseCaseOutputPort<LoginResult>,
) {}
async execute(input: LoginInput): Promise<Result<LoginResult, LoginApplicationError>> {
async execute(input: LoginInput): Promise<Result<void, LoginApplicationError>> {
try {
const emailVO = EmailAddress.create(input.email);
const user = await this.authRepo.findByEmail(emailVO);
if (!user || !user.getPasswordHash()) {
const result = Result.err<LoginResult, LoginApplicationError>({
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<LoginResult, LoginApplicationError>({
return Result.err<LoginApplicationError>({
code: 'INVALID_CREDENTIALS',
details: { message: 'Invalid credentials' },
});
this.output.present(result);
return result;
}
const result = Result.ok<LoginResult, LoginApplicationError>({ 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<LoginInput, LoginResult, LoginError
input,
});
const result = Result.err<LoginResult, LoginApplicationError>({
return Result.err<LoginApplicationError>({
code: 'REPOSITORY_ERROR',
details: { message },
});
this.output.present(result);
return result;
}
}
}

View File

@@ -26,26 +26,24 @@ export type SignupApplicationError = ApplicationErrorCode<SignupErrorCode, { mes
*
* Handles user registration.
*/
export class SignupUseCase implements UseCase<SignupInput, SignupResult, SignupErrorCode> {
export class SignupUseCase implements UseCase<SignupInput, void, SignupErrorCode> {
constructor(
private readonly authRepo: IAuthRepository,
private readonly passwordService: IPasswordHashingService,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<Result<SignupResult, SignupApplicationError>>,
private readonly output: UseCaseOutputPort<SignupResult>,
) {}
async execute(input: SignupInput): Promise<Result<SignupResult, SignupApplicationError>> {
async execute(input: SignupInput): Promise<Result<void, SignupApplicationError>> {
try {
const emailVO = EmailAddress.create(input.email);
const existingUser = await this.authRepo.findByEmail(emailVO);
if (existingUser) {
const result = Result.err<SignupResult, SignupApplicationError>({
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<SignupInput, SignupResult, SignupE
await this.authRepo.save(user);
const result = Result.ok<SignupResult, SignupApplicationError>({ 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<SignupInput, SignupResult, SignupE
input,
});
const result = Result.err<SignupResult, SignupApplicationError>({
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
});
this.output.present(result);
return result;
}
}
}

View File

@@ -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<CompleteDriverOnboardingInput, void, CompleteDriverOnboardingErrorCode> {
constructor(
private readonly driverRepository: IDriverRepository,
private readonly logger: Logger,

View File

@@ -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<DashboardOverviewResult>,
) {}
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',

View File

@@ -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<string | undefined>,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<GetDriversLeaderboardResult>,
) {}
async execute(
_input: GetDriversLeaderboardInput,
): Promise<Result<GetDriversLeaderboardResult, ApplicationErrorCode<GetDriversLeaderboardErrorCode, { message: string }>>> {
this.logger.debug('Executing GetDriversLeaderboardUseCase');
input: GetDriversLeaderboardInput,
): Promise<
Result<
GetDriversLeaderboardResult,
ApplicationErrorCode<GetDriversLeaderboardErrorCode, { message: string }>
>
> {
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' },
});
}
}

View File

@@ -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<GetProfileOverviewInput, void, GetProfileOverviewErrorCode> {
constructor(
private readonly driverRepository: IDriverRepository,
private readonly teamRepository: ITeamRepository,

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