refactor
This commit is contained in:
@@ -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!' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
];
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,6 @@ export class HelloController {
|
||||
@Get()
|
||||
getHello(): { message: string } {
|
||||
const presenter = this.helloService.getHello();
|
||||
return presenter.viewModel;
|
||||
return presenter.responseModel;
|
||||
}
|
||||
}
|
||||
38
apps/api/src/presentation/payments/CreatePaymentPresenter.ts
Normal file
38
apps/api/src/presentation/payments/CreatePaymentPresenter.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
38
apps/api/src/presentation/payments/GetPaymentsPresenter.ts
Normal file
38
apps/api/src/presentation/payments/GetPaymentsPresenter.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
4
apps/api/src/presentation/payments/index.ts
Normal file
4
apps/api/src/presentation/payments/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './types';
|
||||
export * from './CreatePaymentPresenter';
|
||||
export * from './GetPaymentsPresenter';
|
||||
export * from './GetSponsorBillingPresenter';
|
||||
177
apps/api/src/presentation/payments/types.ts
Normal file
177
apps/api/src/presentation/payments/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user