refactor
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
|
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { HelloService } from './hello.service';
|
import { HelloService } from './hello.service';
|
||||||
|
import { HelloPresenter } from './presenters/HelloPresenter';
|
||||||
|
|
||||||
describe('HelloService', () => {
|
describe('HelloService', () => {
|
||||||
let service: HelloService;
|
let service: HelloService;
|
||||||
@@ -19,6 +20,6 @@ describe('HelloService', () => {
|
|||||||
|
|
||||||
it('should return "Hello World!"', () => {
|
it('should return "Hello World!"', () => {
|
||||||
const presenter = service.getHello();
|
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 { Injectable } from '@nestjs/common';
|
||||||
import { HelloPresenter } from './presenters/HelloPresenter';
|
import { Result } from '@core/shared/application/Result';
|
||||||
|
import { HelloPresenter, HelloResponseModel } from './presenters/HelloPresenter';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class HelloService {
|
export class HelloService {
|
||||||
getHello(): HelloPresenter {
|
constructor(private readonly presenter: HelloPresenter) {}
|
||||||
const presenter = new HelloPresenter();
|
|
||||||
presenter.present('Hello World!');
|
getHello(): HelloResponseModel {
|
||||||
return presenter;
|
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;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class HelloPresenter {
|
export class HelloPresenter {
|
||||||
private result: HelloViewModel | null = null;
|
private result: HelloResponseModel | null = null;
|
||||||
|
|
||||||
reset(): void {
|
reset(): void {
|
||||||
this.result = null;
|
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 };
|
this.result = { message };
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewModel(): HelloViewModel {
|
get responseModel(): HelloResponseModel {
|
||||||
if (!this.result) {
|
if (!this.result) {
|
||||||
throw new Error('HelloPresenter not presented');
|
throw new Error('HelloPresenter not presented');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { AnalyticsService } from './AnalyticsService';
|
|||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { EntityType, VisitorType } from '@core/analytics/domain/types/PageView';
|
import { EntityType, VisitorType } from '@core/analytics/domain/types/PageView';
|
||||||
import { EngagementAction, EngagementEntityType } from '@core/analytics/domain/types/EngagementEvent';
|
import { EngagementAction, EngagementEntityType } from '@core/analytics/domain/types/EngagementEvent';
|
||||||
|
import type { RecordEngagementOutputDTO } from './dtos/RecordEngagementOutputDTO';
|
||||||
|
import type { RecordPageViewOutputDTO } from './dtos/RecordPageViewOutputDTO';
|
||||||
|
|
||||||
describe('AnalyticsController', () => {
|
describe('AnalyticsController', () => {
|
||||||
let controller: AnalyticsController;
|
let controller: AnalyticsController;
|
||||||
@@ -42,8 +44,8 @@ describe('AnalyticsController', () => {
|
|||||||
userAgent: 'Mozilla/5.0',
|
userAgent: 'Mozilla/5.0',
|
||||||
country: 'US',
|
country: 'US',
|
||||||
};
|
};
|
||||||
const presenterMock = { viewModel: { pageViewId: 'pv-123' } };
|
const dto: RecordPageViewOutputDTO = { pageViewId: 'pv-123' };
|
||||||
service.recordPageView.mockResolvedValue(presenterMock as any);
|
service.recordPageView.mockResolvedValue(dto);
|
||||||
|
|
||||||
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
|
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
|
||||||
status: vi.fn().mockReturnThis(),
|
status: vi.fn().mockReturnThis(),
|
||||||
@@ -54,7 +56,7 @@ describe('AnalyticsController', () => {
|
|||||||
|
|
||||||
expect(service.recordPageView).toHaveBeenCalledWith(input);
|
expect(service.recordPageView).toHaveBeenCalledWith(input);
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(201);
|
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',
|
actorId: 'actor-789',
|
||||||
metadata: { key: 'value' },
|
metadata: { key: 'value' },
|
||||||
};
|
};
|
||||||
const presenterMock = { eventId: 'event-123', engagementWeight: 10 };
|
const dto: RecordEngagementOutputDTO = { eventId: 'event-123', engagementWeight: 10 };
|
||||||
service.recordEngagement.mockResolvedValue(presenterMock as any);
|
service.recordEngagement.mockResolvedValue(dto);
|
||||||
|
|
||||||
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
|
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
|
||||||
status: vi.fn().mockReturnThis(),
|
status: vi.fn().mockReturnThis(),
|
||||||
@@ -81,45 +83,41 @@ describe('AnalyticsController', () => {
|
|||||||
|
|
||||||
expect(service.recordEngagement).toHaveBeenCalledWith(input);
|
expect(service.recordEngagement).toHaveBeenCalledWith(input);
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(201);
|
expect(mockRes.status).toHaveBeenCalledWith(201);
|
||||||
expect(mockRes.json).toHaveBeenCalledWith((presenterMock as any).viewModel);
|
expect(mockRes.json).toHaveBeenCalledWith(dto);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getDashboardData', () => {
|
describe('getDashboardData', () => {
|
||||||
it('should return dashboard data', async () => {
|
it('should return dashboard data', async () => {
|
||||||
const presenterMock = {
|
const dto = {
|
||||||
viewModel: {
|
totalUsers: 100,
|
||||||
totalUsers: 100,
|
activeUsers: 50,
|
||||||
activeUsers: 50,
|
totalRaces: 20,
|
||||||
totalRaces: 20,
|
totalLeagues: 5,
|
||||||
totalLeagues: 5,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
service.getDashboardData.mockResolvedValue(presenterMock as any);
|
service.getDashboardData.mockResolvedValue(dto);
|
||||||
|
|
||||||
const result = await controller.getDashboardData();
|
const result = await controller.getDashboardData();
|
||||||
|
|
||||||
expect(service.getDashboardData).toHaveBeenCalled();
|
expect(service.getDashboardData).toHaveBeenCalled();
|
||||||
expect(result).toEqual(presenterMock.viewModel);
|
expect(result).toEqual(dto);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getAnalyticsMetrics', () => {
|
describe('getAnalyticsMetrics', () => {
|
||||||
it('should return analytics metrics', async () => {
|
it('should return analytics metrics', async () => {
|
||||||
const presenterMock = {
|
const dto = {
|
||||||
viewModel: {
|
pageViews: 1000,
|
||||||
pageViews: 1000,
|
uniqueVisitors: 500,
|
||||||
uniqueVisitors: 500,
|
averageSessionDuration: 300,
|
||||||
averageSessionDuration: 300,
|
bounceRate: 0.4,
|
||||||
bounceRate: 0.4,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
service.getAnalyticsMetrics.mockResolvedValue(presenterMock as any);
|
service.getAnalyticsMetrics.mockResolvedValue(dto);
|
||||||
|
|
||||||
const result = await controller.getAnalyticsMetrics();
|
const result = await controller.getAnalyticsMetrics();
|
||||||
|
|
||||||
expect(service.getAnalyticsMetrics).toHaveBeenCalled();
|
expect(service.getAnalyticsMetrics).toHaveBeenCalled();
|
||||||
expect(result).toEqual(presenterMock.viewModel);
|
expect(result).toEqual(dto);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -27,8 +27,8 @@ export class AnalyticsController {
|
|||||||
@Body() input: RecordPageViewInput,
|
@Body() input: RecordPageViewInput,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const presenter = await this.analyticsService.recordPageView(input);
|
const dto = await this.analyticsService.recordPageView(input);
|
||||||
res.status(HttpStatus.CREATED).json(presenter.viewModel);
|
res.status(HttpStatus.CREATED).json(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('engagement')
|
@Post('engagement')
|
||||||
@@ -39,23 +39,21 @@ export class AnalyticsController {
|
|||||||
@Body() input: RecordEngagementInput,
|
@Body() input: RecordEngagementInput,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const presenter = await this.analyticsService.recordEngagement(input);
|
const dto = await this.analyticsService.recordEngagement(input);
|
||||||
res.status(HttpStatus.CREATED).json(presenter.viewModel);
|
res.status(HttpStatus.CREATED).json(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('dashboard')
|
@Get('dashboard')
|
||||||
@ApiOperation({ summary: 'Get analytics dashboard data' })
|
@ApiOperation({ summary: 'Get analytics dashboard data' })
|
||||||
@ApiResponse({ status: 200, description: 'Dashboard data', type: GetDashboardDataOutputDTO })
|
@ApiResponse({ status: 200, description: 'Dashboard data', type: GetDashboardDataOutputDTO })
|
||||||
async getDashboardData(): Promise<GetDashboardDataOutputDTO> {
|
async getDashboardData(): Promise<GetDashboardDataOutputDTO> {
|
||||||
const presenter = await this.analyticsService.getDashboardData();
|
return this.analyticsService.getDashboardData();
|
||||||
return presenter.viewModel;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('metrics')
|
@Get('metrics')
|
||||||
@ApiOperation({ summary: 'Get analytics metrics' })
|
@ApiOperation({ summary: 'Get analytics metrics' })
|
||||||
@ApiResponse({ status: 200, description: 'Analytics metrics', type: GetAnalyticsMetricsOutputDTO })
|
@ApiResponse({ status: 200, description: 'Analytics metrics', type: GetAnalyticsMetricsOutputDTO })
|
||||||
async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsOutputDTO> {
|
async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsOutputDTO> {
|
||||||
const presenter = await this.analyticsService.getAnalyticsMetrics();
|
return this.analyticsService.getAnalyticsMetrics();
|
||||||
return presenter.viewModel;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,40 @@
|
|||||||
import { Provider } from '@nestjs/common';
|
import { Provider } from '@nestjs/common';
|
||||||
import { AnalyticsService } from './AnalyticsService';
|
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 { IPageViewRepository } from '@core/analytics/domain/repositories/IPageViewRepository';
|
||||||
import type { IEngagementRepository } from '@core/analytics/domain/repositories/IEngagementRepository';
|
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 Logger_TOKEN = 'Logger_TOKEN';
|
||||||
const IPAGE_VIEW_REPO_TOKEN = 'IPageViewRepository_TOKEN';
|
const IPAGE_VIEW_REPO_TOKEN = 'IPageViewRepository_TOKEN';
|
||||||
const IENGAGEMENT_REPO_TOKEN = 'IEngagementRepository_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 RECORD_PAGE_VIEW_OUTPUT_PORT_TOKEN = 'RecordPageViewOutputPort_TOKEN';
|
||||||
const GET_DASHBOARD_DATA_USE_CASE_TOKEN = 'GetDashboardDataUseCase_TOKEN';
|
const RECORD_ENGAGEMENT_OUTPUT_PORT_TOKEN = 'RecordEngagementOutputPort_TOKEN';
|
||||||
const GET_ANALYTICS_METRICS_USE_CASE_TOKEN = 'GetAnalyticsMetricsUseCase_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 { InMemoryEngagementRepository } from '@adapters/analytics/persistence/inmemory/InMemoryEngagementRepository';
|
||||||
import { InMemoryPageViewRepository } from '@adapters/analytics/persistence/inmemory/InMemoryPageViewRepository';
|
import { InMemoryPageViewRepository } from '@adapters/analytics/persistence/inmemory/InMemoryPageViewRepository';
|
||||||
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
|
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[] = [
|
export const AnalyticsProviders: Provider[] = [
|
||||||
AnalyticsService,
|
AnalyticsService,
|
||||||
|
RecordPageViewPresenter,
|
||||||
|
RecordEngagementPresenter,
|
||||||
|
GetDashboardDataPresenter,
|
||||||
|
GetAnalyticsMetricsPresenter,
|
||||||
{
|
{
|
||||||
provide: Logger_TOKEN,
|
provide: Logger_TOKEN,
|
||||||
useClass: ConsoleLogger,
|
useClass: ConsoleLogger,
|
||||||
@@ -35,23 +48,43 @@ export const AnalyticsProviders: Provider[] = [
|
|||||||
useClass: InMemoryEngagementRepository,
|
useClass: InMemoryEngagementRepository,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: RECORD_PAGE_VIEW_USE_CASE_TOKEN,
|
provide: RECORD_PAGE_VIEW_OUTPUT_PORT_TOKEN,
|
||||||
useFactory: (repo: IPageViewRepository, logger: Logger) => new RecordPageViewUseCase(repo, logger),
|
useExisting: RecordPageViewPresenter,
|
||||||
inject: [IPAGE_VIEW_REPO_TOKEN, Logger_TOKEN],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: RECORD_ENGAGEMENT_USE_CASE_TOKEN,
|
provide: RECORD_ENGAGEMENT_OUTPUT_PORT_TOKEN,
|
||||||
useFactory: (repo: IEngagementRepository, logger: Logger) => new RecordEngagementUseCase(repo, logger),
|
useExisting: RecordEngagementPresenter,
|
||||||
inject: [IENGAGEMENT_REPO_TOKEN, Logger_TOKEN],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: GET_DASHBOARD_DATA_USE_CASE_TOKEN,
|
provide: GET_DASHBOARD_DATA_OUTPUT_PORT_TOKEN,
|
||||||
useFactory: (logger: Logger) => new GetDashboardDataUseCase(logger),
|
useExisting: GetDashboardDataPresenter,
|
||||||
inject: [Logger_TOKEN],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: GET_ANALYTICS_METRICS_USE_CASE_TOKEN,
|
provide: GET_ANALYTICS_METRICS_OUTPUT_PORT_TOKEN,
|
||||||
useFactory: (repo: IPageViewRepository, logger: Logger) => new GetAnalyticsMetricsUseCase(repo, logger),
|
useExisting: GetAnalyticsMetricsPresenter,
|
||||||
inject: [IPAGE_VIEW_REPO_TOKEN, Logger_TOKEN],
|
},
|
||||||
|
{
|
||||||
|
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 RecordPageViewInput = RecordPageViewInputDTO;
|
||||||
type RecordEngagementInput = RecordEngagementInputDTO;
|
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()
|
@Injectable()
|
||||||
export class AnalyticsService {
|
export class AnalyticsService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(RECORD_PAGE_VIEW_USE_CASE_TOKEN) private readonly recordPageViewUseCase: RecordPageViewUseCase,
|
@Inject(RecordPageViewUseCase) private readonly recordPageViewUseCase: RecordPageViewUseCase,
|
||||||
@Inject(RECORD_ENGAGEMENT_USE_CASE_TOKEN) private readonly recordEngagementUseCase: RecordEngagementUseCase,
|
@Inject(RecordEngagementUseCase) private readonly recordEngagementUseCase: RecordEngagementUseCase,
|
||||||
@Inject(GET_DASHBOARD_DATA_USE_CASE_TOKEN) private readonly getDashboardDataUseCase: GetDashboardDataUseCase,
|
@Inject(GetDashboardDataUseCase) private readonly getDashboardDataUseCase: GetDashboardDataUseCase,
|
||||||
@Inject(GET_ANALYTICS_METRICS_USE_CASE_TOKEN) private readonly getAnalyticsMetricsUseCase: GetAnalyticsMetricsUseCase,
|
@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> {
|
async recordPageView(input: RecordPageViewInput): Promise<RecordPageViewOutputDTO> {
|
||||||
const presenter = new RecordPageViewPresenter();
|
const result = await this.recordPageViewUseCase.execute(input);
|
||||||
await this.recordPageViewUseCase.execute(input, presenter);
|
if (result.isErr()) {
|
||||||
return presenter;
|
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> {
|
async recordEngagement(input: RecordEngagementInput): Promise<RecordEngagementOutputDTO> {
|
||||||
const presenter = new RecordEngagementPresenter();
|
const result = await this.recordEngagementUseCase.execute(input);
|
||||||
await this.recordEngagementUseCase.execute(input, presenter);
|
if (result.isErr()) {
|
||||||
return presenter;
|
const error = result.unwrapErr();
|
||||||
|
throw new Error(error.details?.message ?? 'Failed to record engagement');
|
||||||
|
}
|
||||||
|
return this.recordEngagementPresenter.getResponseModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDashboardData(): Promise<GetDashboardDataPresenter> {
|
async getDashboardData(): Promise<GetDashboardDataOutputDTO> {
|
||||||
const presenter = new GetDashboardDataPresenter();
|
const result = await this.getDashboardDataUseCase.execute({});
|
||||||
await this.getDashboardDataUseCase.execute(undefined, presenter);
|
if (result.isErr()) {
|
||||||
return presenter;
|
const error = result.unwrapErr();
|
||||||
|
throw new Error(error.details?.message ?? 'Failed to get dashboard data');
|
||||||
|
}
|
||||||
|
return this.getDashboardDataPresenter.getResponseModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsPresenter> {
|
async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsOutputDTO> {
|
||||||
const presenter = new GetAnalyticsMetricsPresenter();
|
const result = await this.getAnalyticsMetricsUseCase.execute({});
|
||||||
await this.getAnalyticsMetricsUseCase.execute(undefined, presenter);
|
if (result.isErr()) {
|
||||||
return presenter;
|
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();
|
presenter = new GetAnalyticsMetricsPresenter();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps use case output to DTO correctly', () => {
|
it('maps output to DTO correctly', () => {
|
||||||
const output: GetAnalyticsMetricsOutput = {
|
const output: GetAnalyticsMetricsOutput = {
|
||||||
pageViews: 1000,
|
pageViews: 1000,
|
||||||
uniqueVisitors: 500,
|
uniqueVisitors: 500,
|
||||||
@@ -19,7 +19,9 @@ describe('GetAnalyticsMetricsPresenter', () => {
|
|||||||
|
|
||||||
presenter.present(output);
|
presenter.present(output);
|
||||||
|
|
||||||
expect(presenter.viewModel).toEqual({
|
const dto = presenter.getResponseModel();
|
||||||
|
|
||||||
|
expect(dto).toEqual({
|
||||||
pageViews: 1000,
|
pageViews: 1000,
|
||||||
uniqueVisitors: 500,
|
uniqueVisitors: 500,
|
||||||
averageSessionDuration: 300,
|
averageSessionDuration: 300,
|
||||||
@@ -27,19 +29,7 @@ describe('GetAnalyticsMetricsPresenter', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reset clears state and causes viewModel to throw', () => {
|
it('getResponseModel throws if not presented', () => {
|
||||||
const output: GetAnalyticsMetricsOutput = {
|
expect(() => presenter.getResponseModel()).toThrow('Presenter not presented');
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,24 +1,21 @@
|
|||||||
|
import type { UseCaseOutputPort } from '@core/shared/application';
|
||||||
import type { GetAnalyticsMetricsOutput } from '@core/analytics/application/use-cases/GetAnalyticsMetricsUseCase';
|
import type { GetAnalyticsMetricsOutput } from '@core/analytics/application/use-cases/GetAnalyticsMetricsUseCase';
|
||||||
import type { GetAnalyticsMetricsOutputDTO } from '../dtos/GetAnalyticsMetricsOutputDTO';
|
import type { GetAnalyticsMetricsOutputDTO } from '../dtos/GetAnalyticsMetricsOutputDTO';
|
||||||
|
|
||||||
export class GetAnalyticsMetricsPresenter {
|
export class GetAnalyticsMetricsPresenter implements UseCaseOutputPort<GetAnalyticsMetricsOutput> {
|
||||||
private result: GetAnalyticsMetricsOutputDTO | null = null;
|
private responseModel: GetAnalyticsMetricsOutputDTO | null = null;
|
||||||
|
|
||||||
reset() {
|
present(result: GetAnalyticsMetricsOutput): void {
|
||||||
this.result = null;
|
this.responseModel = {
|
||||||
}
|
pageViews: result.pageViews,
|
||||||
|
uniqueVisitors: result.uniqueVisitors,
|
||||||
present(output: GetAnalyticsMetricsOutput): void {
|
averageSessionDuration: result.averageSessionDuration,
|
||||||
this.result = {
|
bounceRate: result.bounceRate,
|
||||||
pageViews: output.pageViews,
|
|
||||||
uniqueVisitors: output.uniqueVisitors,
|
|
||||||
averageSessionDuration: output.averageSessionDuration,
|
|
||||||
bounceRate: output.bounceRate,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewModel(): GetAnalyticsMetricsOutputDTO {
|
getResponseModel(): GetAnalyticsMetricsOutputDTO {
|
||||||
if (!this.result) throw new Error('Presenter not presented');
|
if (!this.responseModel) throw new Error('Presenter not presented');
|
||||||
return this.result;
|
return this.responseModel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ describe('GetDashboardDataPresenter', () => {
|
|||||||
presenter = new GetDashboardDataPresenter();
|
presenter = new GetDashboardDataPresenter();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps use case output to DTO correctly', () => {
|
it('maps output to DTO correctly', () => {
|
||||||
const output: GetDashboardDataOutput = {
|
const output: GetDashboardDataOutput = {
|
||||||
totalUsers: 100,
|
totalUsers: 100,
|
||||||
activeUsers: 50,
|
activeUsers: 50,
|
||||||
@@ -19,7 +19,7 @@ describe('GetDashboardDataPresenter', () => {
|
|||||||
|
|
||||||
presenter.present(output);
|
presenter.present(output);
|
||||||
|
|
||||||
expect(presenter.viewModel).toEqual({
|
expect(presenter.getResponseModel()).toEqual({
|
||||||
totalUsers: 100,
|
totalUsers: 100,
|
||||||
activeUsers: 50,
|
activeUsers: 50,
|
||||||
totalRaces: 20,
|
totalRaces: 20,
|
||||||
@@ -27,19 +27,7 @@ describe('GetDashboardDataPresenter', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reset clears state and causes viewModel to throw', () => {
|
it('getResponseModel throws if not presented', () => {
|
||||||
const output: GetDashboardDataOutput = {
|
expect(() => presenter.getResponseModel()).toThrow('Presenter not presented');
|
||||||
totalUsers: 100,
|
|
||||||
activeUsers: 50,
|
|
||||||
totalRaces: 20,
|
|
||||||
totalLeagues: 5,
|
|
||||||
};
|
|
||||||
|
|
||||||
presenter.present(output);
|
|
||||||
expect(presenter.viewModel).toBeDefined();
|
|
||||||
|
|
||||||
presenter.reset();
|
|
||||||
|
|
||||||
expect(() => presenter.viewModel).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 { GetDashboardDataOutput } from '@core/analytics/application/use-cases/GetDashboardDataUseCase';
|
||||||
import type { GetDashboardDataOutputDTO } from '../dtos/GetDashboardDataOutputDTO';
|
import type { GetDashboardDataOutputDTO } from '../dtos/GetDashboardDataOutputDTO';
|
||||||
|
|
||||||
export class GetDashboardDataPresenter {
|
export class GetDashboardDataPresenter implements UseCaseOutputPort<GetDashboardDataOutput> {
|
||||||
private result: GetDashboardDataOutputDTO | null = null;
|
private responseModel: GetDashboardDataOutputDTO | null = null;
|
||||||
|
|
||||||
reset() {
|
present(result: GetDashboardDataOutput): void {
|
||||||
this.result = null;
|
this.responseModel = {
|
||||||
}
|
totalUsers: result.totalUsers,
|
||||||
|
activeUsers: result.activeUsers,
|
||||||
present(output: GetDashboardDataOutput): void {
|
totalRaces: result.totalRaces,
|
||||||
this.result = {
|
totalLeagues: result.totalLeagues,
|
||||||
totalUsers: output.totalUsers,
|
|
||||||
activeUsers: output.activeUsers,
|
|
||||||
totalRaces: output.totalRaces,
|
|
||||||
totalLeagues: output.totalLeagues,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewModel(): GetDashboardDataOutputDTO {
|
getResponseModel(): GetDashboardDataOutputDTO {
|
||||||
if (!this.result) throw new Error('Presenter not presented');
|
if (!this.responseModel) throw new Error('Presenter not presented');
|
||||||
return this.result;
|
return this.responseModel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,31 +9,21 @@ describe('RecordEngagementPresenter', () => {
|
|||||||
presenter = new RecordEngagementPresenter();
|
presenter = new RecordEngagementPresenter();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps use case output to DTO correctly', () => {
|
it('maps output to DTO correctly', () => {
|
||||||
const output: RecordEngagementOutput = {
|
const output: RecordEngagementOutput = {
|
||||||
eventId: 'event-123',
|
eventId: 'event-123',
|
||||||
engagementWeight: 10,
|
engagementWeight: 10,
|
||||||
} as RecordEngagementOutput;
|
};
|
||||||
|
|
||||||
presenter.present(output);
|
presenter.present(output);
|
||||||
|
|
||||||
expect(presenter.viewModel).toEqual({
|
expect(presenter.getResponseModel()).toEqual({
|
||||||
eventId: 'event-123',
|
eventId: 'event-123',
|
||||||
engagementWeight: 10,
|
engagementWeight: 10,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reset clears state and causes viewModel to throw', () => {
|
it('getResponseModel throws if not presented', () => {
|
||||||
const output: RecordEngagementOutput = {
|
expect(() => presenter.getResponseModel()).toThrow('Presenter not presented');
|
||||||
eventId: 'event-123',
|
|
||||||
engagementWeight: 10,
|
|
||||||
} as RecordEngagementOutput;
|
|
||||||
|
|
||||||
presenter.present(output);
|
|
||||||
expect(presenter.viewModel).toBeDefined();
|
|
||||||
|
|
||||||
presenter.reset();
|
|
||||||
|
|
||||||
expect(() => presenter.viewModel).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 { RecordEngagementOutput } from '@core/analytics/application/use-cases/RecordEngagementUseCase';
|
||||||
import type { RecordEngagementOutputDTO } from '../dtos/RecordEngagementOutputDTO';
|
import type { RecordEngagementOutputDTO } from '../dtos/RecordEngagementOutputDTO';
|
||||||
|
|
||||||
export class RecordEngagementPresenter {
|
export class RecordEngagementPresenter implements UseCaseOutputPort<RecordEngagementOutput> {
|
||||||
private result: RecordEngagementOutputDTO | null = null;
|
private responseModel: RecordEngagementOutputDTO | null = null;
|
||||||
|
|
||||||
reset() {
|
present(result: RecordEngagementOutput): void {
|
||||||
this.result = null;
|
this.responseModel = {
|
||||||
}
|
eventId: result.eventId,
|
||||||
|
engagementWeight: result.engagementWeight,
|
||||||
present(output: RecordEngagementOutput): void {
|
|
||||||
this.result = {
|
|
||||||
eventId: output.eventId,
|
|
||||||
engagementWeight: output.engagementWeight,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewModel(): RecordEngagementOutputDTO {
|
getResponseModel(): RecordEngagementOutputDTO {
|
||||||
if (!this.result) throw new Error('Presenter not presented');
|
if (!this.responseModel) throw new Error('Presenter not presented');
|
||||||
return this.result;
|
return this.responseModel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,28 +9,19 @@ describe('RecordPageViewPresenter', () => {
|
|||||||
presenter = new RecordPageViewPresenter();
|
presenter = new RecordPageViewPresenter();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps use case output to DTO correctly', () => {
|
it('maps output to DTO correctly', () => {
|
||||||
const output: RecordPageViewOutput = {
|
const output: RecordPageViewOutput = {
|
||||||
pageViewId: 'pv-123',
|
pageViewId: 'pv-123',
|
||||||
} as RecordPageViewOutput;
|
};
|
||||||
|
|
||||||
presenter.present(output);
|
presenter.present(output);
|
||||||
|
|
||||||
expect(presenter.viewModel).toEqual({
|
expect(presenter.getResponseModel()).toEqual({
|
||||||
pageViewId: 'pv-123',
|
pageViewId: 'pv-123',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reset clears state and causes viewModel to throw', () => {
|
it('getResponseModel throws if not presented', () => {
|
||||||
const output: RecordPageViewOutput = {
|
expect(() => presenter.getResponseModel()).toThrow('Presenter not presented');
|
||||||
pageViewId: 'pv-123',
|
|
||||||
} as RecordPageViewOutput;
|
|
||||||
|
|
||||||
presenter.present(output);
|
|
||||||
expect(presenter.viewModel).toBeDefined();
|
|
||||||
|
|
||||||
presenter.reset();
|
|
||||||
|
|
||||||
expect(() => presenter.viewModel).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 { RecordPageViewOutput } from '@core/analytics/application/use-cases/RecordPageViewUseCase';
|
||||||
import type { RecordPageViewOutputDTO } from '../dtos/RecordPageViewOutputDTO';
|
import type { RecordPageViewOutputDTO } from '../dtos/RecordPageViewOutputDTO';
|
||||||
|
|
||||||
export class RecordPageViewPresenter {
|
export class RecordPageViewPresenter implements UseCaseOutputPort<RecordPageViewOutput> {
|
||||||
private result: RecordPageViewOutputDTO | null = null;
|
private responseModel: RecordPageViewOutputDTO | null = null;
|
||||||
|
|
||||||
reset() {
|
present(result: RecordPageViewOutput): void {
|
||||||
this.result = null;
|
this.responseModel = {
|
||||||
}
|
pageViewId: result.pageViewId,
|
||||||
|
|
||||||
present(output: RecordPageViewOutput): void {
|
|
||||||
this.result = {
|
|
||||||
pageViewId: output.pageViewId,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewModel(): RecordPageViewOutputDTO {
|
getResponseModel(): RecordPageViewOutputDTO {
|
||||||
if (!this.result) throw new Error('Presenter not presented');
|
if (!this.responseModel) throw new Error('Presenter not presented');
|
||||||
return this.result;
|
return this.responseModel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,21 +5,21 @@ import { SignupParams, LoginParams, AuthSessionDTO } from './dtos/AuthDto';
|
|||||||
|
|
||||||
describe('AuthController', () => {
|
describe('AuthController', () => {
|
||||||
let controller: AuthController;
|
let controller: AuthController;
|
||||||
let service: ReturnType<typeof vi.mocked<AuthService>>;
|
let service: AuthService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
service = vi.mocked<AuthService>({
|
service = {
|
||||||
signupWithEmail: vi.fn(),
|
signupWithEmail: vi.fn(),
|
||||||
loginWithEmail: vi.fn(),
|
loginWithEmail: vi.fn(),
|
||||||
getCurrentSession: vi.fn(),
|
getCurrentSession: vi.fn(),
|
||||||
logout: vi.fn(),
|
logout: vi.fn(),
|
||||||
});
|
} as unknown as AuthService;
|
||||||
|
|
||||||
controller = new AuthController(service);
|
controller = new AuthController(service);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('signup', () => {
|
describe('signup', () => {
|
||||||
it('should call service.signupWithEmail and return session', async () => {
|
it('should call service.signupWithEmail and return session DTO', async () => {
|
||||||
const params: SignupParams = {
|
const params: SignupParams = {
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
password: 'password123',
|
password: 'password123',
|
||||||
@@ -36,7 +36,7 @@ describe('AuthController', () => {
|
|||||||
displayName: 'Test User',
|
displayName: 'Test User',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
service.signupWithEmail.mockResolvedValue(session);
|
(service.signupWithEmail as jest.Mock).mockResolvedValue(session);
|
||||||
|
|
||||||
const result = await controller.signup(params);
|
const result = await controller.signup(params);
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ describe('AuthController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('login', () => {
|
describe('login', () => {
|
||||||
it('should call service.loginWithEmail and return session', async () => {
|
it('should call service.loginWithEmail and return session DTO', async () => {
|
||||||
const params: LoginParams = {
|
const params: LoginParams = {
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
password: 'password123',
|
password: 'password123',
|
||||||
@@ -59,7 +59,7 @@ describe('AuthController', () => {
|
|||||||
displayName: 'Test User',
|
displayName: 'Test User',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
service.loginWithEmail.mockResolvedValue(session);
|
(service.loginWithEmail as jest.Mock).mockResolvedValue(session);
|
||||||
|
|
||||||
const result = await controller.login(params);
|
const result = await controller.login(params);
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ describe('AuthController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('getSession', () => {
|
describe('getSession', () => {
|
||||||
it('should call service.getCurrentSession and return session', async () => {
|
it('should call service.getCurrentSession and return session DTO', async () => {
|
||||||
const session: AuthSessionDTO = {
|
const session: AuthSessionDTO = {
|
||||||
token: 'token123',
|
token: 'token123',
|
||||||
user: {
|
user: {
|
||||||
@@ -78,7 +78,7 @@ describe('AuthController', () => {
|
|||||||
displayName: 'Test User',
|
displayName: 'Test User',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
service.getCurrentSession.mockResolvedValue(session);
|
(service.getCurrentSession as jest.Mock).mockResolvedValue(session);
|
||||||
|
|
||||||
const result = await controller.getSession();
|
const result = await controller.getSession();
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ describe('AuthController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return null if no session', async () => {
|
it('should return null if no session', async () => {
|
||||||
service.getCurrentSession.mockResolvedValue(null);
|
(service.getCurrentSession as jest.Mock).mockResolvedValue(null);
|
||||||
|
|
||||||
const result = await controller.getSession();
|
const result = await controller.getSession();
|
||||||
|
|
||||||
@@ -96,13 +96,14 @@ describe('AuthController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('logout', () => {
|
describe('logout', () => {
|
||||||
it('should call service.logout', async () => {
|
it('should call service.logout and return DTO', async () => {
|
||||||
service.logout.mockResolvedValue(undefined);
|
const dto = { success: true };
|
||||||
|
(service.logout as jest.Mock).mockResolvedValue(dto);
|
||||||
|
|
||||||
await controller.logout();
|
const result = await controller.logout();
|
||||||
|
|
||||||
expect(service.logout).toHaveBeenCalled();
|
expect(service.logout).toHaveBeenCalled();
|
||||||
|
expect(result).toEqual(dto);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
@@ -8,26 +8,21 @@ export class AuthController {
|
|||||||
|
|
||||||
@Post('signup')
|
@Post('signup')
|
||||||
async signup(@Body() params: SignupParams): Promise<AuthSessionDTO> {
|
async signup(@Body() params: SignupParams): Promise<AuthSessionDTO> {
|
||||||
const presenter = await this.authService.signupWithEmail(params);
|
return this.authService.signupWithEmail(params);
|
||||||
return presenter.viewModel;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
async login(@Body() params: LoginParams): Promise<AuthSessionDTO> {
|
async login(@Body() params: LoginParams): Promise<AuthSessionDTO> {
|
||||||
const presenter = await this.authService.loginWithEmail(params);
|
return this.authService.loginWithEmail(params);
|
||||||
return presenter.viewModel;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('session')
|
@Get('session')
|
||||||
async getSession(): Promise<AuthSessionDTO | null> {
|
async getSession(): Promise<AuthSessionDTO | null> {
|
||||||
const presenter = await this.authService.getCurrentSession();
|
return this.authService.getCurrentSession();
|
||||||
return presenter ? presenter.viewModel : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('logout')
|
@Post('logout')
|
||||||
async logout(): Promise<{ success: boolean }> {
|
async logout(): Promise<{ success: boolean }> {
|
||||||
const presenter = await this.authService.logout();
|
return this.authService.logout();
|
||||||
return presenter.viewModel;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,17 @@ import { InMemoryUserRepository } from '@adapters/identity/persistence/inmemory/
|
|||||||
import { InMemoryPasswordHashingService } from '@adapters/identity/services/InMemoryPasswordHashingService';
|
import { InMemoryPasswordHashingService } from '@adapters/identity/services/InMemoryPasswordHashingService';
|
||||||
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
|
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
|
||||||
import { CookieIdentitySessionAdapter } from '@adapters/identity/session/CookieIdentitySessionAdapter';
|
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
|
// Define the tokens for dependency injection
|
||||||
export const AUTH_REPOSITORY_TOKEN = 'IAuthRepository';
|
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 PASSWORD_HASHING_SERVICE_TOKEN = 'IPasswordHashingService';
|
||||||
export const LOGGER_TOKEN = 'Logger';
|
export const LOGGER_TOKEN = 'Logger';
|
||||||
export const IDENTITY_SESSION_PORT_TOKEN = 'IdentitySessionPort';
|
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[] = [
|
export const AuthProviders: Provider[] = [
|
||||||
{
|
{
|
||||||
@@ -57,4 +71,22 @@ export const AuthProviders: Provider[] = [
|
|||||||
useFactory: (logger: Logger) => new CookieIdentitySessionAdapter(logger),
|
useFactory: (logger: Logger) => new CookieIdentitySessionAdapter(logger),
|
||||||
inject: [LOGGER_TOKEN],
|
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
|
// 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 { 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
|
// Core Interfaces and Tokens
|
||||||
import { AuthenticatedUserDTO as CoreAuthenticatedUserDTO } from '@core/identity/application/dto/AuthenticatedUserDTO';
|
|
||||||
import { IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort';
|
import { IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort';
|
||||||
import { User } from '@core/identity/domain/entities/User';
|
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 { IUserRepository } from '@core/identity/domain/repositories/IUserRepository';
|
||||||
import type { IPasswordHashingService } from '@core/identity/domain/services/PasswordHashingService';
|
import type { Logger } from '@core/shared/application';
|
||||||
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 { AUTH_REPOSITORY_TOKEN, IDENTITY_SESSION_PORT_TOKEN, LOGGER_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, USER_REPOSITORY_TOKEN } from './AuthProviders';
|
import { AuthenticatedUserDTO, AuthSessionDTO, LoginParams, SignupParams } from './dtos/AuthDto';
|
||||||
import { AuthSessionDTO, LoginParams, SignupParams, AuthenticatedUserDTO } from './dtos/AuthDto';
|
|
||||||
import { AuthSessionPresenter } from './presenters/AuthSessionPresenter';
|
import { AuthSessionPresenter } from './presenters/AuthSessionPresenter';
|
||||||
|
import type { CommandResultDTO } from './presenters/CommandResultPresenter';
|
||||||
import { CommandResultPresenter } from './presenters/CommandResultPresenter';
|
import { CommandResultPresenter } from './presenters/CommandResultPresenter';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private readonly loginUseCase: LoginUseCase;
|
|
||||||
private readonly signupUseCase: SignupUseCase;
|
|
||||||
private readonly logoutUseCase: LogoutUseCase;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(AUTH_REPOSITORY_TOKEN) private authRepository: IAuthRepository,
|
|
||||||
@Inject(PASSWORD_HASHING_SERVICE_TOKEN) private passwordHashingService: IPasswordHashingService,
|
|
||||||
@Inject(LOGGER_TOKEN) private logger: Logger,
|
@Inject(LOGGER_TOKEN) private logger: Logger,
|
||||||
@Inject(IDENTITY_SESSION_PORT_TOKEN) private identitySessionPort: IdentitySessionPort,
|
@Inject(IDENTITY_SESSION_PORT_TOKEN) private identitySessionPort: IdentitySessionPort,
|
||||||
@Inject(USER_REPOSITORY_TOKEN) private userRepository: IUserRepository, // Inject IUserRepository here
|
@Inject(USER_REPOSITORY_TOKEN) private userRepository: IUserRepository,
|
||||||
) {
|
@Inject(LOGIN_USE_CASE_TOKEN) private readonly loginUseCase: LoginUseCase,
|
||||||
this.loginUseCase = new LoginUseCase(this.authRepository, this.passwordHashingService);
|
@Inject(SIGNUP_USE_CASE_TOKEN) private readonly signupUseCase: SignupUseCase,
|
||||||
this.signupUseCase = new SignupUseCase(this.authRepository, this.passwordHashingService);
|
@Inject(LOGOUT_USE_CASE_TOKEN) private readonly logoutUseCase: LogoutUseCase,
|
||||||
this.logoutUseCase = new LogoutUseCase(this.identitySessionPort);
|
private readonly authSessionPresenter: AuthSessionPresenter,
|
||||||
}
|
private readonly commandResultPresenter: CommandResultPresenter,
|
||||||
|
) {}
|
||||||
|
|
||||||
private mapUserToAuthenticatedUserDTO(user: User): AuthenticatedUserDTO {
|
private mapUserToAuthenticatedUserDTO(user: User): AuthenticatedUserDTO {
|
||||||
return {
|
return {
|
||||||
@@ -44,74 +37,109 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapToCoreAuthenticatedUserDTO(apiDto: AuthenticatedUserDTO): CoreAuthenticatedUserDTO {
|
|
||||||
|
private buildAuthSessionDTO(token: string, user: AuthenticatedUserDTO): AuthSessionDTO {
|
||||||
return {
|
return {
|
||||||
id: apiDto.userId,
|
token,
|
||||||
displayName: apiDto.displayName,
|
user: {
|
||||||
email: apiDto.email,
|
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.');
|
this.logger.debug('[AuthService] Attempting to get current session.');
|
||||||
const coreSession = await this.identitySessionPort.getCurrentSession();
|
const coreSession = await this.identitySessionPort.getCurrentSession();
|
||||||
if (!coreSession) {
|
if (!coreSession) {
|
||||||
return null;
|
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 (!user) {
|
||||||
// If session exists but user doesn't in DB, perhaps clear session?
|
this.logger.warn(
|
||||||
this.logger.warn(`[AuthService] Session found for user ID ${coreSession.user.id}, but user not found in repository.`);
|
`[AuthService] Session found for user ID ${coreSession.user.id}, but user not found in repository.`,
|
||||||
await this.identitySessionPort.clearSession(); // Clear potentially stale session
|
);
|
||||||
|
await this.identitySessionPort.clearSession();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const authenticatedUserDTO = this.mapUserToAuthenticatedUserDTO(User.fromStored(user));
|
const authenticatedUserDTO = this.mapUserToAuthenticatedUserDTO(User.fromStored(user));
|
||||||
|
const apiSession = this.buildAuthSessionDTO(coreSession.token, authenticatedUserDTO);
|
||||||
|
|
||||||
const presenter = new AuthSessionPresenter();
|
return apiSession;
|
||||||
presenter.present({ token: coreSession.token, user: authenticatedUserDTO });
|
|
||||||
return presenter;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async signupWithEmail(params: SignupParams): Promise<AuthSessionPresenter> {
|
async signupWithEmail(params: SignupParams): Promise<AuthSessionDTO> {
|
||||||
this.logger.debug(`[AuthService] Attempting signup for email: ${params.email}`);
|
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 input: SignupInput = {
|
||||||
const authenticatedUserDTO = this.mapUserToAuthenticatedUserDTO(user);
|
email: params.email,
|
||||||
const coreDto = this.mapToCoreAuthenticatedUserDTO(authenticatedUserDTO);
|
password: params.password,
|
||||||
const session = await this.identitySessionPort.createSession(coreDto);
|
displayName: params.displayName,
|
||||||
|
};
|
||||||
|
|
||||||
const presenter = new AuthSessionPresenter();
|
const result = await this.signupUseCase.execute(input);
|
||||||
presenter.present({ token: session.token, user: authenticatedUserDTO });
|
|
||||||
return presenter;
|
|
||||||
}
|
|
||||||
|
|
||||||
async loginWithEmail(params: LoginParams): Promise<AuthSessionPresenter> {
|
if (result.isErr()) {
|
||||||
this.logger.debug(`[AuthService] Attempting login for email: ${params.email}`);
|
const error = result.unwrapErr();
|
||||||
try {
|
throw new Error(error.details?.message ?? 'Signup failed');
|
||||||
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.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.');
|
this.logger.debug('[AuthService] Attempting logout.');
|
||||||
const presenter = new CommandResultPresenter();
|
|
||||||
await this.logoutUseCase.execute();
|
const result = await this.logoutUseCase.execute();
|
||||||
presenter.present({ success: true });
|
|
||||||
return presenter;
|
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 { 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', () => {
|
describe('AuthSessionPresenter', () => {
|
||||||
let presenter: AuthSessionPresenter;
|
let presenter: AuthSessionPresenter;
|
||||||
|
let mockIdentitySessionPort: any;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
presenter = new AuthSessionPresenter();
|
mockIdentitySessionPort = {
|
||||||
|
createSession: vi.fn(),
|
||||||
|
};
|
||||||
|
presenter = new AuthSessionPresenter(mockIdentitySessionPort);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps token and user DTO correctly', () => {
|
it('maps successful result into response model', async () => {
|
||||||
const user: AuthenticatedUserDTO = {
|
const user = User.create({
|
||||||
userId: 'user-1',
|
id: UserId.fromString('user-1'),
|
||||||
email: 'user@example.com',
|
|
||||||
displayName: 'Test User',
|
displayName: 'Test User',
|
||||||
};
|
email: 'user@example.com',
|
||||||
|
passwordHash: { value: 'hash' } as any,
|
||||||
|
});
|
||||||
|
|
||||||
presenter.present({ token: 'token-123', user });
|
const expectedSession = {
|
||||||
|
|
||||||
expect(presenter.viewModel).toEqual({
|
|
||||||
token: 'token-123',
|
token: 'token-123',
|
||||||
user: {
|
user: {
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
email: 'user@example.com',
|
email: 'user@example.com',
|
||||||
displayName: 'Test User',
|
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 });
|
mockIdentitySessionPort.createSession.mockResolvedValue(expectedSession);
|
||||||
expect(presenter.viewModel).toBeDefined();
|
|
||||||
|
|
||||||
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', () => {
|
it('getResponseModel throws when not presented', () => {
|
||||||
expect(presenter.getViewModel()).toBeNull();
|
expect(() => presenter.getResponseModel()).toThrow('Response model not set');
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
present(result: { user: User }): void {
|
||||||
private result: AuthSessionViewModel | null = null;
|
const { user } = result;
|
||||||
|
|
||||||
reset() {
|
this.responseModel = {
|
||||||
this.result = null;
|
userId: user.getId().value,
|
||||||
}
|
email: user.getEmail() ?? '',
|
||||||
|
displayName: user.getDisplayName() ?? '',
|
||||||
present(input: { token: string; user: AuthenticatedUserDTO }): void {
|
|
||||||
this.result = {
|
|
||||||
token: input.token,
|
|
||||||
user: {
|
|
||||||
userId: input.user.userId,
|
|
||||||
email: input.user.email,
|
|
||||||
displayName: input.user.displayName,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewModel(): AuthSessionViewModel {
|
getResponseModel(): AuthenticatedUserDTO {
|
||||||
if (!this.result) throw new Error('Presenter not presented');
|
if (!this.responseModel) {
|
||||||
return this.result;
|
throw new Error('Response model not set');
|
||||||
}
|
}
|
||||||
|
return this.responseModel;
|
||||||
getViewModel(): AuthSessionViewModel | null {
|
|
||||||
return this.result;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { vi } from 'vitest';
|
||||||
import { DashboardController } from './DashboardController';
|
import { DashboardController } from './DashboardController';
|
||||||
import { DashboardService } from './DashboardService';
|
|
||||||
import { DashboardOverviewDTO } from './dtos/DashboardOverviewDTO';
|
import { DashboardOverviewDTO } from './dtos/DashboardOverviewDTO';
|
||||||
|
|
||||||
describe('DashboardController', () => {
|
describe('DashboardController', () => {
|
||||||
@@ -13,7 +11,7 @@ describe('DashboardController', () => {
|
|||||||
getDashboardOverview: vi.fn(),
|
getDashboardOverview: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
controller = new DashboardController(mockService as any);
|
controller = new DashboardController(mockService as never);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getDashboardOverview', () => {
|
describe('getDashboardOverview', () => {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export class DashboardController {
|
|||||||
@ApiQuery({ name: 'driverId', description: 'Driver ID' })
|
@ApiQuery({ name: 'driverId', description: 'Driver ID' })
|
||||||
@ApiResponse({ status: 200, description: 'Dashboard overview', type: DashboardOverviewDTO })
|
@ApiResponse({ status: 200, description: 'Dashboard overview', type: DashboardOverviewDTO })
|
||||||
async getDashboardOverview(@Query('driverId') driverId: string): Promise<DashboardOverviewDTO> {
|
async getDashboardOverview(@Query('driverId') driverId: string): Promise<DashboardOverviewDTO> {
|
||||||
const presenter = await this.dashboardService.getDashboardOverview(driverId);
|
return this.dashboardService.getDashboardOverview(driverId);
|
||||||
return presenter.viewModel;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Provider } from '@nestjs/common';
|
import { Provider } from '@nestjs/common';
|
||||||
import { DashboardService } from './DashboardService';
|
|
||||||
|
|
||||||
// Import core interfaces
|
// Import core interfaces
|
||||||
import type { Logger } from '@core/shared/application/Logger';
|
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 { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
|
||||||
import { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository';
|
import { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository';
|
||||||
import { IResultRepository } from '@core/racing/domain/repositories/IResultRepository';
|
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 { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
|
||||||
import { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository';
|
import { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository';
|
||||||
import { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
|
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 concrete implementations
|
||||||
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
|
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 { InMemoryLeagueMembershipRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueMembershipRepository';
|
||||||
import { InMemoryRaceRegistrationRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRegistrationRepository';
|
import { InMemoryRaceRegistrationRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRegistrationRepository';
|
||||||
import { InMemoryImageServiceAdapter } from '@adapters/media/ports/InMemoryImageServiceAdapter';
|
import { InMemoryImageServiceAdapter } from '@adapters/media/ports/InMemoryImageServiceAdapter';
|
||||||
|
import { DashboardOverviewPresenter } from './presenters/DashboardOverviewPresenter';
|
||||||
|
|
||||||
// Simple mock implementations for missing adapters
|
// Simple mock implementations for missing adapters
|
||||||
class MockFeedRepository implements IFeedRepository {
|
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 [];
|
return [];
|
||||||
}
|
}
|
||||||
async getGlobalFeed(limit?: number) {
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
async getGlobalFeed(_limit?: number) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MockSocialGraphRepository implements ISocialGraphRepository {
|
class MockSocialGraphRepository implements ISocialGraphRepository {
|
||||||
async getFriends(driverId: string) {
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
async getFriends(_driverId: string) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
async getFriendIds(driverId: string) {
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
async getFriendIds(_driverId: string) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
async getSuggestedFriends(driverId: string, limit?: number) {
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
async getSuggestedFriends(_driverId: string, _limit?: number) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,8 +67,11 @@ export const RACE_REGISTRATION_REPOSITORY_TOKEN = 'IRaceRegistrationRepository';
|
|||||||
export const FEED_REPOSITORY_TOKEN = 'IFeedRepository';
|
export const FEED_REPOSITORY_TOKEN = 'IFeedRepository';
|
||||||
export const SOCIAL_GRAPH_REPOSITORY_TOKEN = 'ISocialGraphRepository';
|
export const SOCIAL_GRAPH_REPOSITORY_TOKEN = 'ISocialGraphRepository';
|
||||||
export const IMAGE_SERVICE_TOKEN = 'IImageServicePort';
|
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[] = [
|
export const DashboardProviders: Provider[] = [
|
||||||
|
DashboardOverviewPresenter,
|
||||||
{
|
{
|
||||||
provide: LOGGER_TOKEN,
|
provide: LOGGER_TOKEN,
|
||||||
useClass: ConsoleLogger,
|
useClass: ConsoleLogger,
|
||||||
@@ -113,4 +124,51 @@ export const DashboardProviders: Provider[] = [
|
|||||||
useFactory: (logger: Logger) => new InMemoryImageServiceAdapter(logger),
|
useFactory: (logger: Logger) => new InMemoryImageServiceAdapter(logger),
|
||||||
inject: [LOGGER_TOKEN],
|
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 { Injectable, Inject } from '@nestjs/common';
|
||||||
import { DashboardOverviewUseCase } from '@core/racing/application/use-cases/DashboardOverviewUseCase';
|
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 { DashboardOverviewDTO } from './dtos/DashboardOverviewDTO';
|
||||||
import { DashboardOverviewPresenter } from './presenters/DashboardOverviewPresenter';
|
import { DashboardOverviewPresenter } from './presenters/DashboardOverviewPresenter';
|
||||||
|
|
||||||
// Core imports
|
// Core imports
|
||||||
import type { Logger } from '@core/shared/application/Logger';
|
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
|
// Tokens
|
||||||
import {
|
import { LOGGER_TOKEN, DASHBOARD_OVERVIEW_USE_CASE_TOKEN } from './DashboardProviders';
|
||||||
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';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DashboardService {
|
export class DashboardService {
|
||||||
private readonly dashboardOverviewUseCase: DashboardOverviewUseCase;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
|
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
|
||||||
@Inject(DRIVER_REPOSITORY_TOKEN) private readonly driverRepository?: IDriverRepository,
|
@Inject(DASHBOARD_OVERVIEW_USE_CASE_TOKEN) private readonly dashboardOverviewUseCase: DashboardOverviewUseCase,
|
||||||
@Inject(RACE_REPOSITORY_TOKEN) private readonly raceRepository?: IRaceRepository,
|
private readonly dashboardOverviewPresenter: DashboardOverviewPresenter,
|
||||||
@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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getDashboardOverview(driverId: string): Promise<DashboardOverviewPresenter> {
|
async getDashboardOverview(driverId: string): Promise<DashboardOverviewDTO> {
|
||||||
this.logger.debug('[DashboardService] Getting dashboard overview:', { driverId });
|
this.logger.debug('[DashboardService] Getting dashboard overview:', { driverId });
|
||||||
|
|
||||||
const result = await this.dashboardOverviewUseCase.execute({ driverId });
|
const result = await this.dashboardOverviewUseCase.execute({ driverId });
|
||||||
|
|
||||||
if (result.isErr()) {
|
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();
|
return this.dashboardOverviewPresenter.getResponseModel();
|
||||||
presenter.present(result.value as DashboardOverviewOutputPort);
|
|
||||||
return presenter;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,9 +15,10 @@ export class DashboardDriverSummaryDTO {
|
|||||||
@IsString()
|
@IsString()
|
||||||
country!: string;
|
country!: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty({ nullable: true })
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
avatarUrl!: string;
|
avatarUrl?: string | null;
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
@ApiProperty({ nullable: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -52,13 +53,15 @@ export class DashboardRaceSummaryDTO {
|
|||||||
@IsString()
|
@IsString()
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty({ nullable: true })
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
leagueId!: string;
|
leagueId?: string | null;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty({ nullable: true })
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
leagueName!: string;
|
leagueName?: string | null;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -90,13 +93,15 @@ export class DashboardRecentResultDTO {
|
|||||||
@IsString()
|
@IsString()
|
||||||
raceName!: string;
|
raceName!: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty({ nullable: true })
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
leagueId!: string;
|
leagueId?: string | null;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty({ nullable: true })
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
leagueName!: string;
|
leagueName?: string | null;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -120,17 +125,19 @@ export class DashboardLeagueStandingSummaryDTO {
|
|||||||
@IsString()
|
@IsString()
|
||||||
leagueName!: string;
|
leagueName!: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty({ nullable: true })
|
||||||
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
position!: number;
|
position?: number | null;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
totalDrivers!: number;
|
totalDrivers!: number;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty({ nullable: true })
|
||||||
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
points!: number;
|
points?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DashboardFeedItemSummaryDTO {
|
export class DashboardFeedItemSummaryDTO {
|
||||||
@@ -191,9 +198,10 @@ export class DashboardFriendSummaryDTO {
|
|||||||
@IsString()
|
@IsString()
|
||||||
country!: string;
|
country!: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty({ nullable: true })
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
avatarUrl!: string;
|
avatarUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DashboardOverviewDTO {
|
export class DashboardOverviewDTO {
|
||||||
|
|||||||
@@ -1,120 +1,137 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { DashboardOverviewPresenter } from './DashboardOverviewPresenter';
|
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 => ({
|
const createOutput = (): DashboardOverviewResult => {
|
||||||
currentDriver: {
|
const driver = Driver.create({ id: 'driver-1', iracingId: '12345', name: 'Test Driver', country: 'DE' });
|
||||||
id: 'driver-1',
|
const league1 = League.create({ id: 'league-1', name: 'League 1', description: 'First league', ownerId: 'owner-1' });
|
||||||
name: 'Test Driver',
|
const league2 = League.create({ id: 'league-2', name: 'League 2', description: 'Second league', ownerId: 'owner-2' });
|
||||||
country: 'DE',
|
const league3 = League.create({ id: 'league-3', name: 'League 3', description: 'Third league', ownerId: 'owner-3' });
|
||||||
avatarUrl: 'https://example.com/avatar.jpg',
|
|
||||||
rating: 2500,
|
const race1 = Race.create({
|
||||||
globalRank: 42,
|
|
||||||
totalRaces: 10,
|
|
||||||
wins: 3,
|
|
||||||
podiums: 5,
|
|
||||||
consistency: 90,
|
|
||||||
},
|
|
||||||
myUpcomingRaces: [
|
|
||||||
{
|
|
||||||
id: 'race-1',
|
|
||||||
leagueId: 'league-1',
|
|
||||||
leagueName: 'League 1',
|
|
||||||
track: 'Spa',
|
|
||||||
car: 'GT3',
|
|
||||||
scheduledAt: '2025-01-01T10:00:00Z',
|
|
||||||
status: 'scheduled',
|
|
||||||
isMyLeague: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
otherUpcomingRaces: [
|
|
||||||
{
|
|
||||||
id: 'race-2',
|
|
||||||
leagueId: 'league-2',
|
|
||||||
leagueName: 'League 2',
|
|
||||||
track: 'Monza',
|
|
||||||
car: 'GT3',
|
|
||||||
scheduledAt: '2025-01-02T10:00:00Z',
|
|
||||||
status: 'scheduled',
|
|
||||||
isMyLeague: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
upcomingRaces: [
|
|
||||||
{
|
|
||||||
id: 'race-1',
|
|
||||||
leagueId: 'league-1',
|
|
||||||
leagueName: 'League 1',
|
|
||||||
track: 'Spa',
|
|
||||||
car: 'GT3',
|
|
||||||
scheduledAt: '2025-01-01T10:00:00Z',
|
|
||||||
status: 'scheduled',
|
|
||||||
isMyLeague: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'race-2',
|
|
||||||
leagueId: 'league-2',
|
|
||||||
leagueName: 'League 2',
|
|
||||||
track: 'Monza',
|
|
||||||
car: 'GT3',
|
|
||||||
scheduledAt: '2025-01-02T10:00:00Z',
|
|
||||||
status: 'scheduled',
|
|
||||||
isMyLeague: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
activeLeaguesCount: 2,
|
|
||||||
nextRace: {
|
|
||||||
id: 'race-1',
|
id: 'race-1',
|
||||||
leagueId: 'league-1',
|
leagueId: 'league-1',
|
||||||
leagueName: 'League 1',
|
|
||||||
track: 'Spa',
|
track: 'Spa',
|
||||||
car: 'GT3',
|
car: 'GT3',
|
||||||
scheduledAt: '2025-01-01T10:00:00Z',
|
scheduledAt: new Date('2025-01-01T10:00:00Z'),
|
||||||
status: 'scheduled',
|
status: 'scheduled',
|
||||||
isMyLeague: true,
|
});
|
||||||
},
|
const race2 = Race.create({
|
||||||
recentResults: [
|
id: 'race-2',
|
||||||
{
|
leagueId: 'league-2',
|
||||||
raceId: 'race-3',
|
track: 'Monza',
|
||||||
raceName: 'Nürburgring',
|
car: 'GT3',
|
||||||
leagueId: 'league-3',
|
scheduledAt: new Date('2025-01-02T10:00:00Z'),
|
||||||
leagueName: 'League 3',
|
status: 'scheduled',
|
||||||
finishedAt: '2024-12-01T10:00:00Z',
|
});
|
||||||
position: 1,
|
const race3 = Race.create({
|
||||||
incidents: 0,
|
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,
|
||||||
},
|
},
|
||||||
],
|
myUpcomingRaces: [
|
||||||
leagueStandingsSummaries: [
|
|
||||||
{
|
|
||||||
leagueId: 'league-1',
|
|
||||||
leagueName: 'League 1',
|
|
||||||
position: 1,
|
|
||||||
totalDrivers: 20,
|
|
||||||
points: 150,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
feedSummary: {
|
|
||||||
notificationCount: 3,
|
|
||||||
items: [
|
|
||||||
{
|
{
|
||||||
id: 'feed-1',
|
race: race1,
|
||||||
type: 'race_result' as any,
|
league: league1,
|
||||||
headline: 'You won a race',
|
isMyLeague: true,
|
||||||
body: 'Congrats!',
|
|
||||||
timestamp: '2024-12-02T10:00:00Z',
|
|
||||||
ctaLabel: 'View',
|
|
||||||
ctaHref: '/races/race-3',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
otherUpcomingRaces: [
|
||||||
friends: [
|
{
|
||||||
{
|
race: race2,
|
||||||
id: 'friend-1',
|
league: league2,
|
||||||
name: 'Friend One',
|
isMyLeague: false,
|
||||||
country: 'US',
|
},
|
||||||
avatarUrl: 'https://example.com/friend.jpg',
|
],
|
||||||
|
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', () => {
|
describe('DashboardOverviewPresenter', () => {
|
||||||
let presenter: DashboardOverviewPresenter;
|
let presenter: DashboardOverviewPresenter;
|
||||||
@@ -123,44 +140,23 @@ describe('DashboardOverviewPresenter', () => {
|
|||||||
presenter = new DashboardOverviewPresenter();
|
presenter = new DashboardOverviewPresenter();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps DashboardOverviewOutputPort to DashboardOverviewDTO correctly', () => {
|
it('maps DashboardOverviewResult to DashboardOverviewDTO correctly', () => {
|
||||||
const output = createOutput();
|
const output = createOutput();
|
||||||
|
|
||||||
presenter.present(output);
|
presenter.present(output);
|
||||||
|
const dto = presenter.getResponseModel();
|
||||||
|
|
||||||
const viewModel = presenter.viewModel;
|
expect(dto.activeLeaguesCount).toBe(2);
|
||||||
|
expect(dto.currentDriver?.id).toBe('driver-1');
|
||||||
expect(viewModel.activeLeaguesCount).toBe(2);
|
expect(dto.myUpcomingRaces[0].id).toBe('race-1');
|
||||||
expect(viewModel.currentDriver?.id).toBe('driver-1');
|
expect(dto.otherUpcomingRaces[0].id).toBe('race-2');
|
||||||
expect(viewModel.myUpcomingRaces[0].id).toBe('race-1');
|
expect(dto.upcomingRaces).toHaveLength(2);
|
||||||
expect(viewModel.otherUpcomingRaces[0].id).toBe('race-2');
|
expect(dto.nextRace?.id).toBe('race-1');
|
||||||
expect(viewModel.upcomingRaces).toHaveLength(2);
|
expect(dto.recentResults[0].raceId).toBe('race-3');
|
||||||
expect(viewModel.nextRace?.id).toBe('race-1');
|
expect(dto.leagueStandingsSummaries[0].leagueId).toBe('league-1');
|
||||||
expect(viewModel.recentResults[0].raceId).toBe('race-3');
|
expect(dto.feedSummary.notificationCount).toBe(3);
|
||||||
expect(viewModel.leagueStandingsSummaries[0].leagueId).toBe('league-1');
|
expect(dto.feedSummary.items[0].id).toBe('feed-1');
|
||||||
expect(viewModel.feedSummary.notificationCount).toBe(3);
|
expect(dto.friends[0].id).toBe('friend-1');
|
||||||
expect(viewModel.feedSummary.items[0].id).toBe('feed-1');
|
|
||||||
expect(viewModel.friends[0].id).toBe('friend-1');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reset clears state and causes viewModel to throw', () => {
|
|
||||||
const output = createOutput();
|
|
||||||
presenter.present(output);
|
|
||||||
expect(presenter.viewModel).toBeDefined();
|
|
||||||
|
|
||||||
presenter.reset();
|
|
||||||
|
|
||||||
expect(() => presenter.viewModel).toThrow('Presenter not presented');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getViewModel returns null when not presented', () => {
|
|
||||||
expect(presenter.getViewModel()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getViewModel returns same DTO after present', () => {
|
|
||||||
const output = createOutput();
|
|
||||||
presenter.present(output);
|
|
||||||
|
|
||||||
expect(presenter.getViewModel()).toEqual(presenter.viewModel);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
DashboardOverviewDTO,
|
DashboardOverviewDTO,
|
||||||
DashboardDriverSummaryDTO,
|
DashboardDriverSummaryDTO,
|
||||||
@@ -10,93 +13,89 @@ import {
|
|||||||
DashboardFriendSummaryDTO,
|
DashboardFriendSummaryDTO,
|
||||||
} from '../dtos/DashboardOverviewDTO';
|
} from '../dtos/DashboardOverviewDTO';
|
||||||
|
|
||||||
export class DashboardOverviewPresenter {
|
export class DashboardOverviewPresenter implements UseCaseOutputPort<DashboardOverviewResult> {
|
||||||
private result: DashboardOverviewDTO | null = null;
|
private responseModel: DashboardOverviewDTO | null = null;
|
||||||
|
|
||||||
reset() {
|
present(result: DashboardOverviewResult): void {
|
||||||
this.result = null;
|
const currentDriver: DashboardDriverSummaryDTO | null = result.currentDriver
|
||||||
}
|
|
||||||
|
|
||||||
present(output: DashboardOverviewOutputPort): void {
|
|
||||||
const currentDriver: DashboardDriverSummaryDTO | null = output.currentDriver
|
|
||||||
? {
|
? {
|
||||||
id: output.currentDriver.id,
|
id: result.currentDriver.driver.id,
|
||||||
name: output.currentDriver.name,
|
name: String(result.currentDriver.driver.name),
|
||||||
country: output.currentDriver.country,
|
country: String(result.currentDriver.driver.country),
|
||||||
avatarUrl: output.currentDriver.avatarUrl,
|
avatarUrl: result.currentDriver.avatarUrl,
|
||||||
rating: output.currentDriver.rating,
|
rating: result.currentDriver.rating,
|
||||||
globalRank: output.currentDriver.globalRank,
|
globalRank: result.currentDriver.globalRank,
|
||||||
totalRaces: output.currentDriver.totalRaces,
|
totalRaces: result.currentDriver.totalRaces,
|
||||||
wins: output.currentDriver.wins,
|
wins: result.currentDriver.wins,
|
||||||
podiums: output.currentDriver.podiums,
|
podiums: result.currentDriver.podiums,
|
||||||
consistency: output.currentDriver.consistency,
|
consistency: result.currentDriver.consistency,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const mapRace = (race: typeof output.myUpcomingRaces[number]): DashboardRaceSummaryDTO => ({
|
const mapRace = (raceSummary: DashboardOverviewResult['myUpcomingRaces'][number]): DashboardRaceSummaryDTO => ({
|
||||||
id: race.id,
|
id: raceSummary.race.id,
|
||||||
leagueId: race.leagueId,
|
leagueId: raceSummary.league?.id ? String(raceSummary.league.id) : null,
|
||||||
leagueName: race.leagueName,
|
leagueName: raceSummary.league?.name ? String(raceSummary.league.name) : null,
|
||||||
track: race.track,
|
track: String(raceSummary.race.track),
|
||||||
car: race.car,
|
car: String(raceSummary.race.car),
|
||||||
scheduledAt: race.scheduledAt,
|
scheduledAt: raceSummary.race.scheduledAt.toISOString(),
|
||||||
status: race.status,
|
status: raceSummary.race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
|
||||||
isMyLeague: race.isMyLeague,
|
isMyLeague: raceSummary.isMyLeague,
|
||||||
});
|
});
|
||||||
|
|
||||||
const myUpcomingRaces: DashboardRaceSummaryDTO[] = output.myUpcomingRaces.map(mapRace);
|
const myUpcomingRaces: DashboardRaceSummaryDTO[] = result.myUpcomingRaces.map(mapRace);
|
||||||
const otherUpcomingRaces: DashboardRaceSummaryDTO[] = output.otherUpcomingRaces.map(mapRace);
|
const otherUpcomingRaces: DashboardRaceSummaryDTO[] = result.otherUpcomingRaces.map(mapRace);
|
||||||
const upcomingRaces: DashboardRaceSummaryDTO[] = output.upcomingRaces.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 => ({
|
const recentResults: DashboardRecentResultDTO[] = result.recentResults.map(resultSummary => ({
|
||||||
raceId: result.raceId,
|
raceId: resultSummary.race.id,
|
||||||
raceName: result.raceName,
|
raceName: String(resultSummary.race.track),
|
||||||
leagueId: result.leagueId,
|
leagueId: resultSummary.league?.id ? String(resultSummary.league.id) : null,
|
||||||
leagueName: result.leagueName,
|
leagueName: resultSummary.league?.name ? String(resultSummary.league.name) : null,
|
||||||
finishedAt: result.finishedAt,
|
finishedAt: resultSummary.race.scheduledAt.toISOString(),
|
||||||
position: result.position,
|
position: Number(resultSummary.result.position),
|
||||||
incidents: result.incidents,
|
incidents: Number(resultSummary.result.incidents),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const leagueStandingsSummaries: DashboardLeagueStandingSummaryDTO[] =
|
const leagueStandingsSummaries: DashboardLeagueStandingSummaryDTO[] =
|
||||||
output.leagueStandingsSummaries.map(standing => ({
|
result.leagueStandingsSummaries.map(standing => ({
|
||||||
leagueId: standing.leagueId,
|
leagueId: String(standing.league.id),
|
||||||
leagueName: standing.leagueName,
|
leagueName: String(standing.league.name),
|
||||||
position: standing.position,
|
position: standing.standing?.position ? Number(standing.standing.position) : null,
|
||||||
totalDrivers: standing.totalDrivers,
|
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,
|
id: item.id,
|
||||||
type: item.type,
|
type: String(item.type),
|
||||||
headline: item.headline,
|
headline: item.headline,
|
||||||
body: item.body,
|
body: item.body ?? '',
|
||||||
timestamp: item.timestamp,
|
timestamp: item.timestamp.toISOString(),
|
||||||
ctaLabel: item.ctaLabel,
|
ctaLabel: item.ctaLabel ?? '',
|
||||||
ctaHref: item.ctaHref,
|
ctaHref: item.ctaHref ?? '',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const feedSummary: DashboardFeedSummaryDTO = {
|
const feedSummary: DashboardFeedSummaryDTO = {
|
||||||
notificationCount: output.feedSummary.notificationCount,
|
notificationCount: result.feedSummary.notificationCount,
|
||||||
items: feedItems,
|
items: feedItems,
|
||||||
};
|
};
|
||||||
|
|
||||||
const friends: DashboardFriendSummaryDTO[] = output.friends.map(friend => ({
|
const friends: DashboardFriendSummaryDTO[] = result.friends.map(friend => ({
|
||||||
id: friend.id,
|
id: friend.driver.id,
|
||||||
name: friend.name,
|
name: String(friend.driver.name),
|
||||||
country: friend.country,
|
country: String(friend.driver.country),
|
||||||
avatarUrl: friend.avatarUrl,
|
avatarUrl: friend.avatarUrl,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.result = {
|
this.responseModel = {
|
||||||
currentDriver,
|
currentDriver,
|
||||||
myUpcomingRaces,
|
myUpcomingRaces,
|
||||||
otherUpcomingRaces,
|
otherUpcomingRaces,
|
||||||
upcomingRaces,
|
upcomingRaces,
|
||||||
activeLeaguesCount: output.activeLeaguesCount,
|
activeLeaguesCount: result.activeLeaguesCount,
|
||||||
nextRace,
|
nextRace,
|
||||||
recentResults,
|
recentResults,
|
||||||
leagueStandingsSummaries,
|
leagueStandingsSummaries,
|
||||||
@@ -105,12 +104,8 @@ export class DashboardOverviewPresenter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewModel(): DashboardOverviewDTO {
|
getResponseModel(): DashboardOverviewDTO {
|
||||||
if (!this.result) throw new Error('Presenter not presented');
|
if (!this.responseModel) throw new Error('Presenter not presented');
|
||||||
return this.result;
|
return this.responseModel;
|
||||||
}
|
|
||||||
|
|
||||||
getViewModel(): DashboardOverviewDTO | null {
|
|
||||||
return this.result;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ import { UpdateDriverProfileUseCase } from '@core/racing/application/use-cases/U
|
|||||||
import { IsDriverRegisteredForRaceUseCase } from '@core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase';
|
import { IsDriverRegisteredForRaceUseCase } from '@core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase';
|
||||||
import { GetProfileOverviewUseCase } from '@core/racing/application/use-cases/GetProfileOverviewUseCase';
|
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 concrete in-memory implementations
|
||||||
import { InMemoryDriverRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverRepository';
|
import { InMemoryDriverRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverRepository';
|
||||||
import { InMemoryRankingService } from '@adapters/racing/services/InMemoryRankingService';
|
import { InMemoryRankingService } from '@adapters/racing/services/InMemoryRankingService';
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { Injectable, Inject } from '@nestjs/common';
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
import { CompleteOnboardingInputDTO } from './dtos/CompleteOnboardingInputDTO';
|
import { CompleteOnboardingInputDTO } from './dtos/CompleteOnboardingInputDTO';
|
||||||
import { GetDriverRegistrationStatusQueryDTO } from './dtos/GetDriverRegistrationStatusQueryDTO';
|
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
|
// Use cases
|
||||||
import { GetDriversLeaderboardUseCase } from '@core/racing/application/use-cases/GetDriversLeaderboardUseCase';
|
import { GetDriversLeaderboardUseCase } from '@core/racing/application/use-cases/GetDriversLeaderboardUseCase';
|
||||||
@@ -51,37 +57,42 @@ export class DriverService {
|
|||||||
private readonly driverRepository: IDriverRepository,
|
private readonly driverRepository: IDriverRepository,
|
||||||
@Inject(LOGGER_TOKEN)
|
@Inject(LOGGER_TOKEN)
|
||||||
private readonly logger: Logger,
|
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.');
|
this.logger.debug('[DriverService] Fetching drivers leaderboard.');
|
||||||
|
|
||||||
const result = await this.getDriversLeaderboardUseCase.execute();
|
const result = await this.getDriversLeaderboardUseCase.execute({});
|
||||||
if (result.isErr()) {
|
|
||||||
throw new Error(`Failed to fetch drivers leaderboard: ${result.unwrapErr().details.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const presenter = new DriversLeaderboardPresenter();
|
const presenter = new DriversLeaderboardPresenter();
|
||||||
presenter.reset();
|
presenter.present(result);
|
||||||
presenter.present(result.unwrap());
|
|
||||||
return presenter;
|
return presenter.getResponseModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTotalDrivers(): Promise<DriverStatsPresenter> {
|
async getTotalDrivers(): Promise<DriverStatsDTO> {
|
||||||
this.logger.debug('[DriverService] Fetching total drivers count.');
|
this.logger.debug('[DriverService] Fetching total drivers count.');
|
||||||
|
|
||||||
const result = await this.getTotalDriversUseCase.execute();
|
const result = await this.getTotalDriversUseCase.execute({});
|
||||||
|
|
||||||
if (result.isErr()) {
|
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();
|
return this.driverStatsPresenter.getResponseModel();
|
||||||
presenter.reset();
|
|
||||||
presenter.present(result.unwrap());
|
|
||||||
return presenter;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async completeOnboarding(userId: string, input: CompleteOnboardingInputDTO): Promise<CompleteOnboardingPresenter> {
|
async completeOnboarding(
|
||||||
|
userId: string,
|
||||||
|
input: CompleteOnboardingInputDTO,
|
||||||
|
): Promise<CompleteOnboardingOutputDTO> {
|
||||||
this.logger.debug('Completing onboarding for user:', userId);
|
this.logger.debug('Completing onboarding for user:', userId);
|
||||||
|
|
||||||
const result = await this.completeDriverOnboardingUseCase.execute({
|
const result = await this.completeDriverOnboardingUseCase.execute({
|
||||||
@@ -95,20 +106,14 @@ export class DriverService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const presenter = new CompleteOnboardingPresenter();
|
const presenter = new CompleteOnboardingPresenter();
|
||||||
presenter.reset();
|
presenter.present(result);
|
||||||
|
|
||||||
if (result.isOk()) {
|
return presenter.responseModel;
|
||||||
presenter.present(result.value);
|
|
||||||
} else {
|
|
||||||
presenter.presentError(result.error.code);
|
|
||||||
}
|
|
||||||
|
|
||||||
return presenter;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDriverRegistrationStatus(
|
async getDriverRegistrationStatus(
|
||||||
query: GetDriverRegistrationStatusQueryDTO,
|
query: GetDriverRegistrationStatusQueryDTO,
|
||||||
): Promise<DriverRegistrationStatusPresenter> {
|
): Promise<DriverRegistrationStatusDTO> {
|
||||||
this.logger.debug('Checking driver registration status:', query);
|
this.logger.debug('Checking driver registration status:', query);
|
||||||
|
|
||||||
const result = await this.isDriverRegisteredForRaceUseCase.execute({
|
const result = await this.isDriverRegisteredForRaceUseCase.execute({
|
||||||
@@ -116,77 +121,64 @@ export class DriverService {
|
|||||||
driverId: query.driverId,
|
driverId: query.driverId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.isErr()) {
|
|
||||||
throw new Error(`Failed to check registration status: ${result.unwrapErr().code}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const presenter = new DriverRegistrationStatusPresenter();
|
const presenter = new DriverRegistrationStatusPresenter();
|
||||||
presenter.reset();
|
presenter.present(result);
|
||||||
|
|
||||||
const output = result.unwrap();
|
return presenter.responseModel;
|
||||||
presenter.present(output.isRegistered, output.raceId, output.driverId);
|
|
||||||
|
|
||||||
return presenter;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCurrentDriver(userId: string): Promise<DriverPresenter> {
|
async getCurrentDriver(userId: string): Promise<GetDriverOutputDTO | null> {
|
||||||
this.logger.debug(`[DriverService] Fetching current driver for userId: ${userId}`);
|
this.logger.debug(`[DriverService] Fetching current driver for userId: ${userId}`);
|
||||||
|
|
||||||
const driver = await this.driverRepository.findById(userId);
|
const driver = await this.driverRepository.findById(userId);
|
||||||
|
|
||||||
const presenter = new DriverPresenter();
|
const presenter = new DriverPresenter();
|
||||||
presenter.reset();
|
|
||||||
presenter.present(driver ?? null);
|
presenter.present(driver ?? null);
|
||||||
|
|
||||||
return presenter;
|
return presenter.responseModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateDriverProfile(
|
async updateDriverProfile(
|
||||||
driverId: string,
|
driverId: string,
|
||||||
bio?: string,
|
bio?: string,
|
||||||
country?: string,
|
country?: string,
|
||||||
): Promise<DriverPresenter> {
|
): Promise<GetDriverOutputDTO | null> {
|
||||||
this.logger.debug(`[DriverService] Updating driver profile for driverId: ${driverId}`);
|
this.logger.debug(`[DriverService] Updating driver profile for driverId: ${driverId}`);
|
||||||
|
|
||||||
const result = await this.updateDriverProfileUseCase.execute({ driverId, bio, country });
|
const result = await this.updateDriverProfileUseCase.execute({ driverId, bio, country });
|
||||||
|
|
||||||
const presenter = new DriverPresenter();
|
const presenter = new DriverPresenter();
|
||||||
presenter.reset();
|
|
||||||
|
|
||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
this.logger.error(`Failed to update driver profile: ${result.error.code}`);
|
this.logger.error(`Failed to update driver profile: ${result.error.code}`);
|
||||||
presenter.present(null);
|
presenter.present(null);
|
||||||
return presenter;
|
return presenter.responseModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
presenter.present(result.value);
|
const updatedDriver = await this.driverRepository.findById(driverId);
|
||||||
return presenter;
|
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}`);
|
this.logger.debug(`[DriverService] Fetching driver for driverId: ${driverId}`);
|
||||||
|
|
||||||
const driver = await this.driverRepository.findById(driverId);
|
const driver = await this.driverRepository.findById(driverId);
|
||||||
|
|
||||||
const presenter = new DriverPresenter();
|
const presenter = new DriverPresenter();
|
||||||
presenter.reset();
|
|
||||||
presenter.present(driver ?? null);
|
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}`);
|
this.logger.debug(`[DriverService] Fetching driver profile for driverId: ${driverId}`);
|
||||||
|
|
||||||
const result = await this.getProfileOverviewUseCase.execute({ 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();
|
const presenter = new DriverProfilePresenter();
|
||||||
presenter.reset();
|
presenter.present(result);
|
||||||
presenter.present(result.value);
|
|
||||||
|
|
||||||
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 { CompleteOnboardingOutputDTO } from '../dtos/CompleteOnboardingOutputDTO';
|
||||||
|
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||||
|
|
||||||
export class CompleteOnboardingPresenter {
|
export class CompleteOnboardingPresenter
|
||||||
private result: CompleteOnboardingOutputDTO | null = null;
|
implements UseCaseOutputPort<CompleteDriverOnboardingResult>
|
||||||
|
{
|
||||||
|
private responseModel: CompleteOnboardingOutputDTO | null = null;
|
||||||
|
|
||||||
reset(): void {
|
present(result: CompleteDriverOnboardingResult): void {
|
||||||
this.result = null;
|
this.responseModel = {
|
||||||
}
|
|
||||||
|
|
||||||
present(output: CompleteDriverOnboardingOutputPort): void {
|
|
||||||
this.result = {
|
|
||||||
success: true,
|
success: true,
|
||||||
driverId: output.driverId,
|
driverId: result.driver.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
presentError(errorCode: string): void {
|
getResponseModel(): CompleteOnboardingOutputDTO {
|
||||||
this.result = {
|
if (!this.responseModel) throw new Error('Presenter not presented');
|
||||||
success: false,
|
return this.responseModel;
|
||||||
errorMessage: errorCode,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
get viewModel(): CompleteOnboardingOutputDTO {
|
|
||||||
if (!this.result) throw new Error('Presenter not presented');
|
|
||||||
return this.result;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,19 +2,15 @@ import type { Driver } from '@core/racing/domain/entities/Driver';
|
|||||||
import type { GetDriverOutputDTO } from '../dtos/GetDriverOutputDTO';
|
import type { GetDriverOutputDTO } from '../dtos/GetDriverOutputDTO';
|
||||||
|
|
||||||
export class DriverPresenter {
|
export class DriverPresenter {
|
||||||
private result: GetDriverOutputDTO | null = null;
|
private responseModel: GetDriverOutputDTO | null = null;
|
||||||
|
|
||||||
reset(): void {
|
|
||||||
this.result = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
present(driver: Driver | null): void {
|
present(driver: Driver | null): void {
|
||||||
if (!driver) {
|
if (!driver) {
|
||||||
this.result = null;
|
this.responseModel = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.result = {
|
this.responseModel = {
|
||||||
id: driver.id,
|
id: driver.id,
|
||||||
iracingId: driver.iracingId.toString(),
|
iracingId: driver.iracingId.toString(),
|
||||||
name: driver.name.toString(),
|
name: driver.name.toString(),
|
||||||
@@ -24,7 +20,7 @@ export class DriverPresenter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewModel(): GetDriverOutputDTO | null {
|
getResponseModel(): GetDriverOutputDTO | null {
|
||||||
return this.result;
|
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 { GetDriverProfileOutputDTO } from '../dtos/GetDriverProfileOutputDTO';
|
||||||
|
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||||
|
|
||||||
export class DriverProfilePresenter {
|
export class DriverProfilePresenter
|
||||||
private result: GetDriverProfileOutputDTO | null = null;
|
implements UseCaseOutputPort<GetProfileOverviewResult>
|
||||||
|
{
|
||||||
|
private responseModel: GetDriverProfileOutputDTO | null = null;
|
||||||
|
|
||||||
reset(): void {
|
present(result: GetProfileOverviewResult): void {
|
||||||
this.result = null;
|
this.responseModel = {
|
||||||
}
|
currentDriver: result.driverInfo
|
||||||
|
|
||||||
present(output: ProfileOverviewOutputPort): void {
|
|
||||||
this.result = {
|
|
||||||
currentDriver: output.driver
|
|
||||||
? {
|
? {
|
||||||
id: output.driver.id,
|
id: result.driverInfo.driver.id,
|
||||||
name: output.driver.name,
|
name: result.driverInfo.driver.name.toString(),
|
||||||
country: output.driver.country,
|
country: result.driverInfo.driver.country.toString(),
|
||||||
avatarUrl: output.driver.avatarUrl,
|
avatarUrl: this.getAvatarUrl(result.driverInfo.driver.id) || '',
|
||||||
iracingId: output.driver.iracingId,
|
iracingId: result.driverInfo.driver.iracingId.toString(),
|
||||||
joinedAt: output.driver.joinedAt.toISOString(),
|
joinedAt: result.driverInfo.driver.joinedAt.toDate().toISOString(),
|
||||||
rating: output.driver.rating,
|
rating: result.driverInfo.rating,
|
||||||
globalRank: output.driver.globalRank,
|
globalRank: result.driverInfo.globalRank,
|
||||||
consistency: output.driver.consistency,
|
consistency: result.driverInfo.consistency,
|
||||||
bio: output.driver.bio,
|
bio: result.driverInfo.driver.bio?.toString() || null,
|
||||||
totalDrivers: output.driver.totalDrivers,
|
totalDrivers: result.driverInfo.totalDrivers,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
stats: output.stats,
|
stats: result.stats,
|
||||||
finishDistribution: output.finishDistribution,
|
finishDistribution: result.finishDistribution,
|
||||||
teamMemberships: output.teamMemberships.map(membership => ({
|
teamMemberships: result.teamMemberships.map(membership => ({
|
||||||
teamId: membership.teamId,
|
teamId: membership.team.id,
|
||||||
teamName: membership.teamName,
|
teamName: membership.team.name.toString(),
|
||||||
teamTag: membership.teamTag,
|
teamTag: membership.team.tag.toString(),
|
||||||
role: membership.role,
|
role: membership.membership.role,
|
||||||
joinedAt: membership.joinedAt.toISOString(),
|
joinedAt: membership.membership.joinedAt.toISOString(),
|
||||||
isCurrent: membership.isCurrent,
|
isCurrent: true, // TODO: check membership status
|
||||||
})),
|
})),
|
||||||
socialSummary: output.socialSummary,
|
socialSummary: {
|
||||||
extendedProfile: output.extendedProfile,
|
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 {
|
getResponseModel(): GetDriverProfileOutputDTO {
|
||||||
if (!this.result) throw new Error('Presenter not presented');
|
if (!this.responseModel) throw new Error('Presenter not presented');
|
||||||
return this.result;
|
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 { DriverRegistrationStatusDTO } from '../dtos/DriverRegistrationStatusDTO';
|
||||||
|
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||||
|
|
||||||
export class DriverRegistrationStatusPresenter {
|
export class DriverRegistrationStatusPresenter
|
||||||
private result: DriverRegistrationStatusDTO | null = null;
|
implements UseCaseOutputPort<IsDriverRegisteredForRaceResult>
|
||||||
|
{
|
||||||
|
private responseModel: DriverRegistrationStatusDTO | null = null;
|
||||||
|
|
||||||
reset(): void {
|
present(result: IsDriverRegisteredForRaceResult): void {
|
||||||
this.result = null;
|
this.responseModel = {
|
||||||
}
|
isRegistered: result.isRegistered,
|
||||||
|
raceId: result.raceId,
|
||||||
present(isRegistered: boolean, raceId: string, driverId: string): void {
|
driverId: result.driverId,
|
||||||
this.result = {
|
|
||||||
isRegistered,
|
|
||||||
raceId,
|
|
||||||
driverId,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewModel(): DriverRegistrationStatusDTO {
|
getResponseModel(): DriverRegistrationStatusDTO {
|
||||||
if (!this.result) {
|
if (!this.responseModel) throw new Error('Presenter not presented');
|
||||||
throw new Error('Presenter not presented');
|
return this.responseModel;
|
||||||
}
|
|
||||||
|
|
||||||
return this.result;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { Result } from '@core/shared/application/Result';
|
||||||
import { DriverStatsPresenter } from './DriverStatsPresenter';
|
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', () => {
|
describe('DriverStatsPresenter', () => {
|
||||||
let presenter: DriverStatsPresenter;
|
let presenter: DriverStatsPresenter;
|
||||||
@@ -10,16 +11,18 @@ describe('DriverStatsPresenter', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('present', () => {
|
describe('present', () => {
|
||||||
it('should map core DTO to API view model correctly', () => {
|
it('should map core result to API response model correctly', () => {
|
||||||
const dto: TotalDriversResultDTO = {
|
const output: GetTotalDriversResult = {
|
||||||
totalDrivers: 42,
|
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,
|
totalDrivers: 42,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -27,15 +30,17 @@ describe('DriverStatsPresenter', () => {
|
|||||||
|
|
||||||
describe('reset', () => {
|
describe('reset', () => {
|
||||||
it('should reset the result', () => {
|
it('should reset the result', () => {
|
||||||
const dto: TotalDriversResultDTO = {
|
const output: GetTotalDriversResult = {
|
||||||
totalDrivers: 10,
|
totalDrivers: 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
presenter.present(dto);
|
const result = Result.ok<GetTotalDriversResult, never>(output);
|
||||||
expect(presenter.viewModel).toBeDefined();
|
|
||||||
|
presenter.present(result);
|
||||||
|
expect(presenter.responseModel).toBeDefined();
|
||||||
|
|
||||||
presenter.reset();
|
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 { 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 {
|
export class DriverStatsPresenter
|
||||||
private result: DriverStatsDTO | null = null;
|
implements UseCaseOutputPort<GetTotalDriversResult>
|
||||||
|
{
|
||||||
|
private responseModel: DriverStatsDTO | null = null;
|
||||||
|
|
||||||
reset() {
|
present(result: GetTotalDriversResult): void {
|
||||||
this.result = null;
|
this.responseModel = {
|
||||||
}
|
totalDrivers: result.totalDrivers,
|
||||||
|
|
||||||
present(output: TotalDriversOutputPort) {
|
|
||||||
this.result = {
|
|
||||||
totalDrivers: output.totalDrivers,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewModel(): DriverStatsDTO {
|
getResponseModel(): DriverStatsDTO {
|
||||||
if (!this.result) throw new Error('Presenter not presented');
|
if (!this.responseModel) throw new Error('Presenter not presented');
|
||||||
return this.result;
|
return this.responseModel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { Result } from '@core/shared/application/Result';
|
||||||
import { DriversLeaderboardPresenter } from './DriversLeaderboardPresenter';
|
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', () => {
|
describe('DriversLeaderboardPresenter', () => {
|
||||||
let presenter: DriversLeaderboardPresenter;
|
let presenter: DriversLeaderboardPresenter;
|
||||||
@@ -10,41 +11,50 @@ describe('DriversLeaderboardPresenter', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('present', () => {
|
describe('present', () => {
|
||||||
it('should map core DTO to API view model correctly', () => {
|
it('should map core result to API response model correctly', () => {
|
||||||
const dto: DriversLeaderboardResultDTO = {
|
const coreResult: GetDriversLeaderboardResult = {
|
||||||
drivers: [
|
items: [
|
||||||
{
|
{
|
||||||
id: 'driver-1',
|
driver: {
|
||||||
name: 'Driver One',
|
id: 'driver-1',
|
||||||
country: 'US',
|
name: 'Driver One' as any,
|
||||||
iracingId: '12345',
|
country: 'US' as any,
|
||||||
joinedAt: new Date('2023-01-01'),
|
} 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',
|
driver: {
|
||||||
name: 'Driver Two',
|
id: 'driver-2',
|
||||||
country: 'DE',
|
name: 'Driver Two' as any,
|
||||||
iracingId: '67890',
|
country: 'DE' as any,
|
||||||
joinedAt: new Date('2023-01-02'),
|
} 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: [
|
totalRaces: 90,
|
||||||
{ driverId: 'driver-1', rating: 2500, overallRank: 1 },
|
totalWins: 15,
|
||||||
{ driverId: 'driver-2', rating: 2400, overallRank: 2 },
|
activeCount: 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',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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).toHaveLength(2);
|
||||||
expect(result.drivers[0]).toEqual({
|
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 { 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 {
|
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 {
|
const output = result.unwrap();
|
||||||
this.result = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
present(output: DriversLeaderboardOutputPort): void {
|
return {
|
||||||
this.result = {
|
drivers: output.items.map(item => ({
|
||||||
drivers: output.drivers.map(driver => ({
|
id: item.driver.id,
|
||||||
id: driver.id,
|
name: item.driver.name.toString(),
|
||||||
name: driver.name,
|
rating: item.rating,
|
||||||
rating: driver.rating,
|
skillLevel: item.skillLevel,
|
||||||
skillLevel: driver.skillLevel,
|
nationality: item.driver.country.toString(),
|
||||||
nationality: driver.nationality,
|
racesCompleted: item.racesCompleted,
|
||||||
racesCompleted: driver.racesCompleted,
|
wins: item.wins,
|
||||||
wins: driver.wins,
|
podiums: item.podiums,
|
||||||
podiums: driver.podiums,
|
isActive: item.isActive,
|
||||||
isActive: driver.isActive,
|
rank: item.rank,
|
||||||
rank: driver.rank,
|
avatarUrl: item.avatarUrl,
|
||||||
avatarUrl: driver.avatarUrl,
|
|
||||||
})),
|
})),
|
||||||
totalRaces: output.totalRaces,
|
totalRaces: output.items.reduce((sum, d) => sum + d.racesCompleted, 0),
|
||||||
totalWins: output.totalWins,
|
totalWins: output.items.reduce((sum, d) => sum + d.wins, 0),
|
||||||
activeCount: output.activeCount,
|
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 { GetLeagueWalletUseCase } from '@core/racing/application/use-cases/GetLeagueWalletUseCase';
|
||||||
import { WithdrawFromLeagueWalletUseCase } from '@core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase';
|
import { WithdrawFromLeagueWalletUseCase } from '@core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase';
|
||||||
|
|
||||||
|
// Import presenters
|
||||||
|
import { AllLeaguesWithCapacityPresenter } from './presenters/AllLeaguesWithCapacityPresenter';
|
||||||
|
|
||||||
// Define injection tokens
|
// Define injection tokens
|
||||||
export const LEAGUE_REPOSITORY_TOKEN = 'ILeagueRepository';
|
export const LEAGUE_REPOSITORY_TOKEN = 'ILeagueRepository';
|
||||||
export const LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN = 'ILeagueMembershipRepository';
|
export const LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN = 'ILeagueMembershipRepository';
|
||||||
@@ -137,8 +140,18 @@ export const LeagueProviders: Provider[] = [
|
|||||||
provide: LOGGER_TOKEN,
|
provide: LOGGER_TOKEN,
|
||||||
useClass: ConsoleLogger,
|
useClass: ConsoleLogger,
|
||||||
},
|
},
|
||||||
|
// Presenters
|
||||||
|
{
|
||||||
|
provide: 'AllLeaguesWithCapacityPresenter',
|
||||||
|
useClass: AllLeaguesWithCapacityPresenter,
|
||||||
|
},
|
||||||
// Use cases
|
// 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,
|
provide: GET_LEAGUE_STANDINGS_USE_CASE,
|
||||||
useClass: GetLeagueStandingsUseCaseImpl,
|
useClass: GetLeagueStandingsUseCaseImpl,
|
||||||
|
|||||||
@@ -135,9 +135,7 @@ export class LeagueService {
|
|||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
const presenter = new AllLeaguesWithCapacityPresenter();
|
return this.getAllLeaguesWithCapacityUseCase.outputPort.present(result);
|
||||||
presenter.present(result.unwrap());
|
|
||||||
return presenter.getViewModel()!;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTotalLeagues(): Promise<TotalLeaguesDTO> {
|
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 { 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 {
|
export class AllLeaguesWithCapacityPresenter implements UseCaseOutputPort<GetAllLeaguesWithCapacityResult, 'REPOSITORY_ERROR'> {
|
||||||
private result: AllLeaguesWithCapacityDTO | null = null;
|
present(result: Result<GetAllLeaguesWithCapacityResult, ApplicationErrorCode<'REPOSITORY_ERROR'>>): AllLeaguesWithCapacityDTO {
|
||||||
|
const output = result.unwrap();
|
||||||
reset() {
|
|
||||||
this.result = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
present(output: AllLeaguesWithCapacityOutputPort) {
|
|
||||||
const leagues: LeagueWithCapacityDTO[] = output.leagues.map(league => ({
|
const leagues: LeagueWithCapacityDTO[] = output.leagues.map(league => ({
|
||||||
id: league.id,
|
id: league.league.id.toString(),
|
||||||
name: league.name,
|
name: league.league.name.toString(),
|
||||||
description: league.description,
|
description: league.league.description?.toString() || '',
|
||||||
ownerId: league.ownerId,
|
ownerId: league.league.ownerId.toString(),
|
||||||
settings: { maxDrivers: league.settings.maxDrivers || 0 },
|
settings: { maxDrivers: league.maxDrivers },
|
||||||
createdAt: league.createdAt.toISOString(),
|
createdAt: league.league.createdAt.toDate().toISOString(),
|
||||||
usedSlots: output.memberCounts[league.id] || 0,
|
usedSlots: league.currentDrivers,
|
||||||
socialLinks: league.socialLinks,
|
socialLinks: league.league.socialLinks || {},
|
||||||
}));
|
}));
|
||||||
this.result = {
|
return {
|
||||||
leagues,
|
leagues,
|
||||||
totalCount: leagues.length,
|
totalCount: leagues.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewModel(): AllLeaguesWithCapacityDTO | null {
|
|
||||||
return this.result;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -5,10 +5,16 @@ import { MediaService } from './MediaService';
|
|||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { RequestAvatarGenerationInputDTO } from './dtos/RequestAvatarGenerationInputDTO';
|
import { RequestAvatarGenerationInputDTO } from './dtos/RequestAvatarGenerationInputDTO';
|
||||||
import { UploadMediaInputDTO } from './dtos/UploadMediaInputDTO';
|
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', () => {
|
describe('MediaController', () => {
|
||||||
let controller: MediaController;
|
let controller: MediaController;
|
||||||
let service: ReturnType<typeof vi.mocked<MediaService>>;
|
let service: jest.Mocked<MediaService>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
@@ -29,153 +35,201 @@ describe('MediaController', () => {
|
|||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
controller = module.get<MediaController>(MediaController);
|
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', () => {
|
describe('requestAvatarGeneration', () => {
|
||||||
it('should request avatar generation and return 201 on success', async () => {
|
it('should request avatar generation and return 201 on success', async () => {
|
||||||
const input: RequestAvatarGenerationInputDTO = { driverId: 'driver-123' };
|
const input: RequestAvatarGenerationInputDTO = {
|
||||||
const viewModel = { success: true, jobId: 'job-123' } as any;
|
userId: 'user-123',
|
||||||
service.requestAvatarGeneration.mockResolvedValue({ viewModel } as any);
|
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>> = {
|
const res = createMockResponse();
|
||||||
status: vi.fn().mockReturnThis(),
|
|
||||||
json: vi.fn(),
|
|
||||||
} as unknown as ReturnType<typeof vi.mocked<Response>>;
|
|
||||||
|
|
||||||
await controller.requestAvatarGeneration(input, mockRes);
|
await controller.requestAvatarGeneration(input, res);
|
||||||
|
|
||||||
expect(service.requestAvatarGeneration).toHaveBeenCalledWith(input);
|
expect(service.requestAvatarGeneration).toHaveBeenCalledWith(input);
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(201);
|
expect(res.status).toHaveBeenCalledWith(201);
|
||||||
expect(mockRes.json).toHaveBeenCalledWith(viewModel);
|
expect(res.json).toHaveBeenCalledWith(dto);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 400 on failure', async () => {
|
it('should return 400 on failure', async () => {
|
||||||
const input: RequestAvatarGenerationInputDTO = { driverId: 'driver-123' };
|
const input: RequestAvatarGenerationInputDTO = {
|
||||||
const viewModel = { success: false, error: 'Error' } as any;
|
userId: 'user-123',
|
||||||
service.requestAvatarGeneration.mockResolvedValue({ viewModel } as any);
|
facePhotoData: 'photo-data',
|
||||||
|
suitColor: 'red',
|
||||||
|
};
|
||||||
|
const dto: RequestAvatarGenerationOutputDTO = {
|
||||||
|
success: false,
|
||||||
|
errorMessage: 'Error',
|
||||||
|
};
|
||||||
|
service.requestAvatarGeneration.mockResolvedValue(dto);
|
||||||
|
|
||||||
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
|
const res = createMockResponse();
|
||||||
status: vi.fn().mockReturnThis(),
|
|
||||||
json: vi.fn(),
|
|
||||||
} as unknown as ReturnType<typeof vi.mocked<Response>>;
|
|
||||||
|
|
||||||
await controller.requestAvatarGeneration(input, mockRes);
|
await controller.requestAvatarGeneration(input, res);
|
||||||
|
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
expect(service.requestAvatarGeneration).toHaveBeenCalledWith(input);
|
||||||
expect(mockRes.json).toHaveBeenCalledWith(viewModel);
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(dto);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('uploadMedia', () => {
|
describe('uploadMedia', () => {
|
||||||
it('should upload media and return 201 on success', async () => {
|
it('should upload media and return 201 on success', async () => {
|
||||||
const file: Express.Multer.File = { filename: 'file.jpg' } as Express.Multer.File;
|
const file: Express.Multer.File = { filename: 'file.jpg' } as Express.Multer.File;
|
||||||
const input: UploadMediaInputDTO = { type: 'image' };
|
const input: UploadMediaInputDTO = { type: 'image' } as UploadMediaInputDTO;
|
||||||
const viewModel = { success: true, mediaId: 'media-123' } as any;
|
const dto: UploadMediaOutputDTO = {
|
||||||
service.uploadMedia.mockResolvedValue({ viewModel } as any);
|
success: true,
|
||||||
|
mediaId: 'media-123',
|
||||||
|
url: 'https://example.com/file.jpg',
|
||||||
|
};
|
||||||
|
service.uploadMedia.mockResolvedValue(dto);
|
||||||
|
|
||||||
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
|
const res = createMockResponse();
|
||||||
status: vi.fn().mockReturnThis(),
|
|
||||||
json: vi.fn(),
|
|
||||||
} as unknown as ReturnType<typeof vi.mocked<Response>>;
|
|
||||||
|
|
||||||
await controller.uploadMedia(file, input, mockRes);
|
await controller.uploadMedia(file, input, res);
|
||||||
|
|
||||||
expect(service.uploadMedia).toHaveBeenCalledWith({ ...input, file });
|
expect(service.uploadMedia).toHaveBeenCalledWith({ ...input, file });
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(201);
|
expect(res.status).toHaveBeenCalledWith(201);
|
||||||
expect(mockRes.json).toHaveBeenCalledWith(viewModel);
|
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', () => {
|
describe('getMedia', () => {
|
||||||
it('should return media if found', async () => {
|
it('should return media if found', async () => {
|
||||||
const mediaId = 'media-123';
|
const mediaId = 'media-123';
|
||||||
const viewModel = { id: mediaId, url: 'url' } as any;
|
const dto: GetMediaOutputDTO = {
|
||||||
service.getMedia.mockResolvedValue({ viewModel } as any);
|
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>> = {
|
const res = createMockResponse();
|
||||||
status: vi.fn().mockReturnThis(),
|
|
||||||
json: vi.fn(),
|
|
||||||
} as unknown as ReturnType<typeof vi.mocked<Response>>;
|
|
||||||
|
|
||||||
await controller.getMedia(mediaId, mockRes);
|
await controller.getMedia(mediaId, res);
|
||||||
|
|
||||||
expect(service.getMedia).toHaveBeenCalledWith(mediaId);
|
expect(service.getMedia).toHaveBeenCalledWith(mediaId);
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
expect(res.status).toHaveBeenCalledWith(200);
|
||||||
expect(mockRes.json).toHaveBeenCalledWith(viewModel);
|
expect(res.json).toHaveBeenCalledWith(dto);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 404 if not found', async () => {
|
it('should return 404 if not found', async () => {
|
||||||
const mediaId = 'media-123';
|
const mediaId = 'media-123';
|
||||||
service.getMedia.mockResolvedValue({ viewModel: null } as any);
|
service.getMedia.mockResolvedValue(null);
|
||||||
|
|
||||||
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
|
const res = createMockResponse();
|
||||||
status: vi.fn().mockReturnThis(),
|
|
||||||
json: vi.fn(),
|
|
||||||
} as unknown as ReturnType<typeof vi.mocked<Response>>;
|
|
||||||
|
|
||||||
await controller.getMedia(mediaId, mockRes);
|
await controller.getMedia(mediaId, res);
|
||||||
|
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(404);
|
expect(service.getMedia).toHaveBeenCalledWith(mediaId);
|
||||||
expect(mockRes.json).toHaveBeenCalledWith({ error: 'Media not found' });
|
expect(res.status).toHaveBeenCalledWith(404);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'Media not found' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('deleteMedia', () => {
|
describe('deleteMedia', () => {
|
||||||
it('should delete media', async () => {
|
it('should delete media and return result', async () => {
|
||||||
const mediaId = 'media-123';
|
const mediaId = 'media-123';
|
||||||
const viewModel = { success: true } as any;
|
const dto: DeleteMediaOutputDTO = {
|
||||||
service.deleteMedia.mockResolvedValue({ viewModel } as any);
|
success: true,
|
||||||
|
};
|
||||||
|
service.deleteMedia.mockResolvedValue(dto);
|
||||||
|
|
||||||
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
|
const res = createMockResponse();
|
||||||
status: vi.fn().mockReturnThis(),
|
|
||||||
json: vi.fn(),
|
|
||||||
} as unknown as ReturnType<typeof vi.mocked<Response>>;
|
|
||||||
|
|
||||||
await controller.deleteMedia(mediaId, mockRes);
|
await controller.deleteMedia(mediaId, res);
|
||||||
|
|
||||||
expect(service.deleteMedia).toHaveBeenCalledWith(mediaId);
|
expect(service.deleteMedia).toHaveBeenCalledWith(mediaId);
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
expect(res.status).toHaveBeenCalledWith(200);
|
||||||
expect(mockRes.json).toHaveBeenCalledWith(viewModel);
|
expect(res.json).toHaveBeenCalledWith(dto);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getAvatar', () => {
|
describe('getAvatar', () => {
|
||||||
it('should return avatar if found', async () => {
|
it('should return avatar if found', async () => {
|
||||||
const driverId = 'driver-123';
|
const driverId = 'driver-123';
|
||||||
const result = { url: 'avatar.jpg' };
|
const dto: GetAvatarOutputDTO = {
|
||||||
service.getAvatar.mockResolvedValue(result);
|
avatarUrl: 'https://example.com/avatar.png',
|
||||||
|
};
|
||||||
|
service.getAvatar.mockResolvedValue(dto);
|
||||||
|
|
||||||
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
|
const res = createMockResponse();
|
||||||
status: vi.fn().mockReturnThis(),
|
|
||||||
json: vi.fn(),
|
|
||||||
} as unknown as ReturnType<typeof vi.mocked<Response>>;
|
|
||||||
|
|
||||||
await controller.getAvatar(driverId, mockRes);
|
await controller.getAvatar(driverId, res);
|
||||||
|
|
||||||
expect(service.getAvatar).toHaveBeenCalledWith(driverId);
|
expect(service.getAvatar).toHaveBeenCalledWith(driverId);
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
expect(res.status).toHaveBeenCalledWith(200);
|
||||||
expect(mockRes.json).toHaveBeenCalledWith(result);
|
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', () => {
|
describe('updateAvatar', () => {
|
||||||
it('should update avatar', async () => {
|
it('should update avatar and return result', async () => {
|
||||||
const driverId = 'driver-123';
|
const driverId = 'driver-123';
|
||||||
const input = { url: 'new-avatar.jpg' };
|
const input = { mediaUrl: 'https://example.com/new-avatar.png' } as UpdateAvatarOutputDTO;
|
||||||
const result = { success: true };
|
const dto: UpdateAvatarOutputDTO = {
|
||||||
service.updateAvatar.mockResolvedValue(result);
|
success: true,
|
||||||
|
};
|
||||||
|
service.updateAvatar.mockResolvedValue(dto);
|
||||||
|
|
||||||
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
|
const res = createMockResponse();
|
||||||
status: vi.fn().mockReturnThis(),
|
|
||||||
json: vi.fn(),
|
|
||||||
} as unknown as ReturnType<typeof vi.mocked<Response>>;
|
|
||||||
|
|
||||||
await controller.updateAvatar(driverId, input, mockRes);
|
await controller.updateAvatar(driverId, input as any, res);
|
||||||
|
|
||||||
expect(service.updateAvatar).toHaveBeenCalledWith(driverId, input);
|
expect(service.updateAvatar).toHaveBeenCalledWith(driverId, input as any);
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
expect(res.status).toHaveBeenCalledWith(200);
|
||||||
expect(mockRes.json).toHaveBeenCalledWith(result);
|
expect(res.json).toHaveBeenCalledWith(dto);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,13 +29,12 @@ export class MediaController {
|
|||||||
@Body() input: RequestAvatarGenerationInput,
|
@Body() input: RequestAvatarGenerationInput,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const presenter = await this.mediaService.requestAvatarGeneration(input);
|
const dto: RequestAvatarGenerationOutputDTO = await this.mediaService.requestAvatarGeneration(input);
|
||||||
const viewModel = presenter.viewModel;
|
|
||||||
|
|
||||||
if (viewModel.success) {
|
if (dto.success) {
|
||||||
res.status(HttpStatus.CREATED).json(viewModel);
|
res.status(HttpStatus.CREATED).json(dto);
|
||||||
} else {
|
} 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,
|
@Body() input: UploadMediaInput,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const presenter = await this.mediaService.uploadMedia({ ...input, file });
|
const dto: UploadMediaOutputDTO = await this.mediaService.uploadMedia({ ...input, file });
|
||||||
const viewModel = presenter.viewModel;
|
|
||||||
|
|
||||||
if (viewModel.success) {
|
if (dto.success) {
|
||||||
res.status(HttpStatus.CREATED).json(viewModel);
|
res.status(HttpStatus.CREATED).json(dto);
|
||||||
} else {
|
} 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,
|
@Param('mediaId') mediaId: string,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const presenter = await this.mediaService.getMedia(mediaId);
|
const dto: GetMediaOutputDTO | null = await this.mediaService.getMedia(mediaId);
|
||||||
const viewModel = presenter.viewModel;
|
|
||||||
|
|
||||||
if (viewModel) {
|
if (dto) {
|
||||||
res.status(HttpStatus.OK).json(viewModel);
|
res.status(HttpStatus.OK).json(dto);
|
||||||
} else {
|
} else {
|
||||||
res.status(HttpStatus.NOT_FOUND).json({ error: 'Media not found' });
|
res.status(HttpStatus.NOT_FOUND).json({ error: 'Media not found' });
|
||||||
}
|
}
|
||||||
@@ -85,10 +82,9 @@ export class MediaController {
|
|||||||
@Param('mediaId') mediaId: string,
|
@Param('mediaId') mediaId: string,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const presenter = await this.mediaService.deleteMedia(mediaId);
|
const dto: DeleteMediaOutputDTO = await this.mediaService.deleteMedia(mediaId);
|
||||||
const viewModel = presenter.viewModel;
|
|
||||||
|
|
||||||
res.status(HttpStatus.OK).json(viewModel);
|
res.status(HttpStatus.OK).json(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('avatar/:driverId')
|
@Get('avatar/:driverId')
|
||||||
@@ -99,11 +95,10 @@ export class MediaController {
|
|||||||
@Param('driverId') driverId: string,
|
@Param('driverId') driverId: string,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const presenter = await this.mediaService.getAvatar(driverId);
|
const dto: GetAvatarOutputDTO | null = await this.mediaService.getAvatar(driverId);
|
||||||
const viewModel = presenter.viewModel;
|
|
||||||
|
|
||||||
if (viewModel) {
|
if (dto) {
|
||||||
res.status(HttpStatus.OK).json(viewModel);
|
res.status(HttpStatus.OK).json(dto);
|
||||||
} else {
|
} else {
|
||||||
res.status(HttpStatus.NOT_FOUND).json({ error: 'Avatar not found' });
|
res.status(HttpStatus.NOT_FOUND).json({ error: 'Avatar not found' });
|
||||||
}
|
}
|
||||||
@@ -118,9 +113,8 @@ export class MediaController {
|
|||||||
@Body() input: UpdateAvatarInput,
|
@Body() input: UpdateAvatarInput,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const presenter = await this.mediaService.updateAvatar(driverId, input);
|
const dto: UpdateAvatarOutputDTO = await this.mediaService.updateAvatar(driverId, input);
|
||||||
const viewModel = presenter.viewModel;
|
|
||||||
|
|
||||||
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 { RequestAvatarGenerationInputDTO } from './dtos/RequestAvatarGenerationInputDTO';
|
||||||
import type { UploadMediaInputDTO } from './dtos/UploadMediaInputDTO';
|
import type { UploadMediaInputDTO } from './dtos/UploadMediaInputDTO';
|
||||||
import type { UpdateAvatarInputDTO } from './dtos/UpdateAvatarInputDTO';
|
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';
|
import type { RacingSuitColor } from '@core/media/domain/types/AvatarGenerationRequest';
|
||||||
|
|
||||||
type RequestAvatarGenerationInput = RequestAvatarGenerationInputDTO;
|
type RequestAvatarGenerationInput = RequestAvatarGenerationInputDTO;
|
||||||
@@ -32,89 +38,116 @@ import {
|
|||||||
DELETE_MEDIA_USE_CASE_TOKEN,
|
DELETE_MEDIA_USE_CASE_TOKEN,
|
||||||
GET_AVATAR_USE_CASE_TOKEN,
|
GET_AVATAR_USE_CASE_TOKEN,
|
||||||
UPDATE_AVATAR_USE_CASE_TOKEN,
|
UPDATE_AVATAR_USE_CASE_TOKEN,
|
||||||
LOGGER_TOKEN
|
LOGGER_TOKEN,
|
||||||
} from './MediaProviders';
|
} from './MediaProviders';
|
||||||
import type { Logger } from '@core/shared/application';
|
import type { Logger } from '@core/shared/application';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MediaService {
|
export class MediaService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN) private readonly requestAvatarGenerationUseCase: RequestAvatarGenerationUseCase,
|
@Inject(REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN)
|
||||||
@Inject(UPLOAD_MEDIA_USE_CASE_TOKEN) private readonly uploadMediaUseCase: UploadMediaUseCase,
|
private readonly requestAvatarGenerationUseCase: RequestAvatarGenerationUseCase,
|
||||||
@Inject(GET_MEDIA_USE_CASE_TOKEN) private readonly getMediaUseCase: GetMediaUseCase,
|
@Inject(UPLOAD_MEDIA_USE_CASE_TOKEN)
|
||||||
@Inject(DELETE_MEDIA_USE_CASE_TOKEN) private readonly deleteMediaUseCase: DeleteMediaUseCase,
|
private readonly uploadMediaUseCase: UploadMediaUseCase,
|
||||||
@Inject(GET_AVATAR_USE_CASE_TOKEN) private readonly getAvatarUseCase: GetAvatarUseCase,
|
@Inject(GET_MEDIA_USE_CASE_TOKEN)
|
||||||
@Inject(UPDATE_AVATAR_USE_CASE_TOKEN) private readonly updateAvatarUseCase: UpdateAvatarUseCase,
|
private readonly getMediaUseCase: GetMediaUseCase,
|
||||||
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
|
@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.');
|
this.logger.debug('[MediaService] Requesting avatar generation.');
|
||||||
|
|
||||||
const presenter = new RequestAvatarGenerationPresenter();
|
const presenter = new RequestAvatarGenerationPresenter();
|
||||||
await this.requestAvatarGenerationUseCase.execute({
|
presenter.reset();
|
||||||
|
|
||||||
|
const result = await this.requestAvatarGenerationUseCase.execute({
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
facePhotoData: input.facePhotoData,
|
facePhotoData: input.facePhotoData,
|
||||||
suitColor: input.suitColor as RacingSuitColor,
|
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.');
|
this.logger.debug('[MediaService] Uploading media.');
|
||||||
|
|
||||||
const presenter = new UploadMediaPresenter();
|
const presenter = new UploadMediaPresenter();
|
||||||
|
presenter.reset();
|
||||||
|
|
||||||
await this.uploadMediaUseCase.execute({
|
const result = await this.uploadMediaUseCase.execute({
|
||||||
file: input.file,
|
file: input.file,
|
||||||
uploadedBy: input.userId, // Assuming userId is the uploader
|
uploadedBy: input.userId ?? '',
|
||||||
metadata: input.metadata,
|
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}`);
|
this.logger.debug(`[MediaService] Getting media: ${mediaId}`);
|
||||||
|
|
||||||
const presenter = new GetMediaPresenter();
|
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}`);
|
this.logger.debug(`[MediaService] Deleting media: ${mediaId}`);
|
||||||
|
|
||||||
const presenter = new DeleteMediaPresenter();
|
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}`);
|
this.logger.debug(`[MediaService] Getting avatar for driver: ${driverId}`);
|
||||||
|
|
||||||
const presenter = new GetAvatarPresenter();
|
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}`);
|
this.logger.debug(`[MediaService] Updating avatar for driver: ${driverId}`);
|
||||||
|
|
||||||
const presenter = new UpdateAvatarPresenter();
|
const presenter = new UpdateAvatarPresenter();
|
||||||
|
presenter.reset();
|
||||||
await this.updateAvatarUseCase.execute({
|
|
||||||
|
const result = await this.updateAvatarUseCase.execute({
|
||||||
driverId,
|
driverId,
|
||||||
mediaUrl: input.mediaUrl,
|
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';
|
import type { DeleteMediaOutputDTO } from '../dtos/DeleteMediaOutputDTO';
|
||||||
|
|
||||||
type DeleteMediaOutput = DeleteMediaOutputDTO;
|
type DeleteMediaResponseModel = DeleteMediaOutputDTO;
|
||||||
|
|
||||||
export class DeleteMediaPresenter implements IDeleteMediaPresenter {
|
export type DeleteMediaApplicationError = ApplicationErrorCode<
|
||||||
private result: DeleteMediaResult | null = null;
|
DeleteMediaErrorCode,
|
||||||
|
{ message: string }
|
||||||
|
>;
|
||||||
|
|
||||||
present(result: DeleteMediaResult) {
|
export class DeleteMediaPresenter {
|
||||||
this.result = result;
|
private model: DeleteMediaResponseModel | null = null;
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.model = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewModel(): DeleteMediaOutput {
|
present(result: Result<DeleteMediaResult, DeleteMediaApplicationError>): void {
|
||||||
if (!this.result) throw new Error('Presenter not presented');
|
if (result.isErr()) {
|
||||||
|
const error = result.unwrapErr();
|
||||||
|
|
||||||
return {
|
this.model = {
|
||||||
success: this.result.success,
|
success: false,
|
||||||
error: this.result.errorMessage,
|
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';
|
import type { GetAvatarOutputDTO } from '../dtos/GetAvatarOutputDTO';
|
||||||
|
|
||||||
export type GetAvatarViewModel = GetAvatarOutputDTO | null;
|
export type GetAvatarResponseModel = GetAvatarOutputDTO | null;
|
||||||
|
|
||||||
export class GetAvatarPresenter implements IGetAvatarPresenter {
|
export type GetAvatarApplicationError = ApplicationErrorCode<
|
||||||
private result: GetAvatarResult | null = null;
|
GetAvatarErrorCode,
|
||||||
|
{ message: string }
|
||||||
|
>;
|
||||||
|
|
||||||
present(result: GetAvatarResult) {
|
export class GetAvatarPresenter {
|
||||||
this.result = result;
|
private model: GetAvatarResponseModel | null = null;
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.model = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewModel(): GetAvatarViewModel {
|
present(result: Result<GetAvatarResult, GetAvatarApplicationError>): void {
|
||||||
if (!this.result || !this.result.success || !this.result.avatar) {
|
if (result.isErr()) {
|
||||||
return null;
|
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 {
|
const output = result.unwrap();
|
||||||
avatarUrl: this.result.avatar.mediaUrl,
|
|
||||||
|
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';
|
import type { GetMediaOutputDTO } from '../dtos/GetMediaOutputDTO';
|
||||||
|
|
||||||
// The HTTP-facing DTO (or null when not found)
|
export type GetMediaResponseModel = GetMediaOutputDTO | null;
|
||||||
export type GetMediaViewModel = GetMediaOutputDTO | null;
|
|
||||||
|
|
||||||
export class GetMediaPresenter implements IGetMediaPresenter {
|
export type GetMediaApplicationError = ApplicationErrorCode<
|
||||||
private result: GetMediaResult | null = null;
|
GetMediaErrorCode,
|
||||||
|
{ message: string }
|
||||||
|
>;
|
||||||
|
|
||||||
present(result: GetMediaResult) {
|
export class GetMediaPresenter {
|
||||||
this.result = result;
|
private model: GetMediaResponseModel | null = null;
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.model = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewModel(): GetMediaViewModel {
|
present(result: Result<GetMediaResult, GetMediaApplicationError>): void {
|
||||||
if (!this.result || !this.result.success || !this.result.media) {
|
if (result.isErr()) {
|
||||||
return null;
|
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,
|
id: media.id,
|
||||||
url: media.url,
|
url: media.url,
|
||||||
type: media.type,
|
type: media.type,
|
||||||
@@ -28,4 +43,12 @@ export class GetMediaPresenter implements IGetMediaPresenter {
|
|||||||
size: media.size,
|
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 { 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 {
|
export type RequestAvatarGenerationApplicationError = ApplicationErrorCode<
|
||||||
private result: RequestAvatarGenerationOutput | null = null;
|
RequestAvatarGenerationErrorCode,
|
||||||
|
{ message: string }
|
||||||
|
>;
|
||||||
|
|
||||||
|
export class RequestAvatarGenerationPresenter {
|
||||||
|
private model: RequestAvatarGenerationResponseModel | null = null;
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
this.result = null;
|
this.model = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
present(dto: RequestAvatarGenerationResultDTO) {
|
present(
|
||||||
this.result = {
|
result: Result<
|
||||||
success: dto.status === 'completed',
|
RequestAvatarGenerationResult,
|
||||||
requestId: dto.requestId,
|
RequestAvatarGenerationApplicationError
|
||||||
avatarUrls: dto.avatarUrls,
|
>,
|
||||||
errorMessage: dto.errorMessage,
|
): 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 {
|
getResponseModel(): RequestAvatarGenerationResponseModel | null {
|
||||||
if (!this.result) throw new Error('Presenter not presented');
|
return this.model;
|
||||||
return this.result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewModel(): RequestAvatarGenerationOutput {
|
get responseModel(): RequestAvatarGenerationResponseModel {
|
||||||
return this.viewModel;
|
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';
|
import type { UpdateAvatarOutputDTO } from '../dtos/UpdateAvatarOutputDTO';
|
||||||
|
|
||||||
type UpdateAvatarOutput = UpdateAvatarOutputDTO;
|
type UpdateAvatarResponseModel = 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');
|
|
||||||
|
|
||||||
return {
|
export type UpdateAvatarApplicationError = ApplicationErrorCode<
|
||||||
success: this.result.success,
|
UpdateAvatarErrorCode,
|
||||||
error: this.result.errorMessage,
|
{ 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';
|
import type { UploadMediaOutputDTO } from '../dtos/UploadMediaOutputDTO';
|
||||||
|
|
||||||
type UploadMediaOutput = UploadMediaOutputDTO;
|
type UploadMediaResponseModel = UploadMediaOutputDTO;
|
||||||
|
|
||||||
export class UploadMediaPresenter implements IUploadMediaPresenter {
|
export type UploadMediaApplicationError = ApplicationErrorCode<
|
||||||
private result: UploadMediaResult | null = null;
|
UploadMediaErrorCode,
|
||||||
|
{ message: string }
|
||||||
|
>;
|
||||||
|
|
||||||
present(result: UploadMediaResult) {
|
export class UploadMediaPresenter {
|
||||||
this.result = result;
|
private model: UploadMediaResponseModel | null = null;
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.model = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewModel(): UploadMediaOutput {
|
present(result: Result<UploadMediaResult, UploadMediaApplicationError>): void {
|
||||||
if (!this.result) throw new Error('Presenter not presented');
|
if (result.isErr()) {
|
||||||
|
const error = result.unwrapErr();
|
||||||
|
|
||||||
if (this.result.success) {
|
this.model = {
|
||||||
return {
|
success: false,
|
||||||
success: true,
|
error: error.details?.message ?? 'Upload failed',
|
||||||
mediaId: this.result.mediaId,
|
|
||||||
url: this.result.url,
|
|
||||||
};
|
};
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const output = result.unwrap();
|
||||||
success: false,
|
|
||||||
error: this.result.errorMessage || 'Upload failed',
|
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';
|
} from '@core/payments/application/presenters/ICreatePaymentPresenter';
|
||||||
|
|
||||||
export class CreatePaymentPresenter implements ICreatePaymentPresenter {
|
export class CreatePaymentPresenter implements ICreatePaymentPresenter {
|
||||||
private result: CreatePaymentViewModel | null = null;
|
private responseModel: CreatePaymentViewModel | null = null;
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
this.result = null;
|
this.responseModel = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
present(dto: CreatePaymentResultDTO) {
|
present(dto: CreatePaymentResultDTO) {
|
||||||
this.result = dto;
|
this.responseModel = dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewModel(): CreatePaymentViewModel | null {
|
getResponseModel(): CreatePaymentViewModel | null {
|
||||||
return this.result;
|
return this.responseModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewModel(): CreatePaymentViewModel {
|
get responseModel(): CreatePaymentViewModel {
|
||||||
if (!this.result) throw new Error('Presenter not presented');
|
if (!this.responseModel) throw new Error('Presenter not presented');
|
||||||
return this.result;
|
return this.responseModel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ import { ForbiddenException, InternalServerErrorException, NotFoundException } f
|
|||||||
import { ProtestsController } from './ProtestsController';
|
import { ProtestsController } from './ProtestsController';
|
||||||
import { ProtestsService } from './ProtestsService';
|
import { ProtestsService } from './ProtestsService';
|
||||||
import { ReviewProtestCommandDTO } from '../race/dtos/ReviewProtestCommandDTO';
|
import { ReviewProtestCommandDTO } from '../race/dtos/ReviewProtestCommandDTO';
|
||||||
import type { ReviewProtestPresenter } from './presenters/ReviewProtestPresenter';
|
import type { ReviewProtestResponseDTO } from './presenters/ReviewProtestPresenter';
|
||||||
|
|
||||||
describe('ProtestsController', () => {
|
describe('ProtestsController', () => {
|
||||||
let controller: ProtestsController;
|
let controller: ProtestsController;
|
||||||
@@ -28,15 +28,7 @@ describe('ProtestsController', () => {
|
|||||||
reviewProtestMock = vi.mocked(service.reviewProtest);
|
reviewProtestMock = vi.mocked(service.reviewProtest);
|
||||||
});
|
});
|
||||||
|
|
||||||
const successPresenter = (viewModel: ReviewProtestPresenter['viewModel']): ReviewProtestPresenter => ({
|
const successDto = (dto: ReviewProtestResponseDTO): ReviewProtestResponseDTO => dto;
|
||||||
get viewModel() {
|
|
||||||
return viewModel;
|
|
||||||
},
|
|
||||||
getViewModel: () => viewModel,
|
|
||||||
reset: vi.fn(),
|
|
||||||
presentSuccess: vi.fn(),
|
|
||||||
presentError: vi.fn(),
|
|
||||||
} as unknown as ReviewProtestPresenter);
|
|
||||||
|
|
||||||
describe('reviewProtest', () => {
|
describe('reviewProtest', () => {
|
||||||
it('should call service and not throw on success', async () => {
|
it('should call service and not throw on success', async () => {
|
||||||
@@ -48,7 +40,7 @@ describe('ProtestsController', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
reviewProtestMock.mockResolvedValue(
|
reviewProtestMock.mockResolvedValue(
|
||||||
successPresenter({
|
successDto({
|
||||||
success: true,
|
success: true,
|
||||||
protestId,
|
protestId,
|
||||||
stewardId: body.stewardId,
|
stewardId: body.stewardId,
|
||||||
@@ -70,7 +62,7 @@ describe('ProtestsController', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
reviewProtestMock.mockResolvedValue(
|
reviewProtestMock.mockResolvedValue(
|
||||||
successPresenter({
|
successDto({
|
||||||
success: false,
|
success: false,
|
||||||
errorCode: 'PROTEST_NOT_FOUND',
|
errorCode: 'PROTEST_NOT_FOUND',
|
||||||
message: 'Protest not found',
|
message: 'Protest not found',
|
||||||
@@ -89,7 +81,7 @@ describe('ProtestsController', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
reviewProtestMock.mockResolvedValue(
|
reviewProtestMock.mockResolvedValue(
|
||||||
successPresenter({
|
successDto({
|
||||||
success: false,
|
success: false,
|
||||||
errorCode: 'NOT_LEAGUE_ADMIN',
|
errorCode: 'NOT_LEAGUE_ADMIN',
|
||||||
message: 'Not authorized',
|
message: 'Not authorized',
|
||||||
@@ -108,7 +100,7 @@ describe('ProtestsController', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
reviewProtestMock.mockResolvedValue(
|
reviewProtestMock.mockResolvedValue(
|
||||||
successPresenter({
|
successDto({
|
||||||
success: false,
|
success: false,
|
||||||
errorCode: 'UNEXPECTED_ERROR',
|
errorCode: 'UNEXPECTED_ERROR',
|
||||||
message: 'Unexpected',
|
message: 'Unexpected',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Body, Controller, ForbiddenException, HttpCode, HttpStatus, InternalSer
|
|||||||
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { ProtestsService } from './ProtestsService';
|
import { ProtestsService } from './ProtestsService';
|
||||||
import { ReviewProtestCommandDTO } from '../race/dtos/ReviewProtestCommandDTO';
|
import { ReviewProtestCommandDTO } from '../race/dtos/ReviewProtestCommandDTO';
|
||||||
|
import type { ReviewProtestResponseDTO } from './presenters/ReviewProtestPresenter';
|
||||||
|
|
||||||
@ApiTags('protests')
|
@ApiTags('protests')
|
||||||
@Controller('protests')
|
@Controller('protests')
|
||||||
@@ -17,19 +18,18 @@ export class ProtestsController {
|
|||||||
@Param('protestId') protestId: string,
|
@Param('protestId') protestId: string,
|
||||||
@Body() body: Omit<ReviewProtestCommandDTO, 'protestId'>,
|
@Body() body: Omit<ReviewProtestCommandDTO, 'protestId'>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const presenter = await this.protestsService.reviewProtest({ protestId, ...body });
|
const result: ReviewProtestResponseDTO = await this.protestsService.reviewProtest({ protestId, ...body });
|
||||||
const viewModel = presenter.viewModel;
|
|
||||||
|
|
||||||
if (!viewModel.success) {
|
if (!result.success) {
|
||||||
switch (viewModel.errorCode) {
|
switch (result.errorCode) {
|
||||||
case 'PROTEST_NOT_FOUND':
|
case 'PROTEST_NOT_FOUND':
|
||||||
throw new NotFoundException(viewModel.message ?? 'Protest not found');
|
throw new NotFoundException(result.message ?? 'Protest not found');
|
||||||
case 'RACE_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':
|
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:
|
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 { describe, it, expect, beforeEach, vi, type MockedFunction } from 'vitest';
|
||||||
import { Result } from '@core/shared/application/Result';
|
import { Result } from '@core/shared/application/Result';
|
||||||
import type { Logger } from '@core/shared/application/Logger';
|
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 { ProtestsService } from './ProtestsService';
|
||||||
import type { ReviewProtestPresenter } from './presenters/ReviewProtestPresenter';
|
import type { ReviewProtestResponseDTO } from './presenters/ReviewProtestPresenter';
|
||||||
|
|
||||||
describe('ProtestsService', () => {
|
describe('ProtestsService', () => {
|
||||||
let service: ProtestsService;
|
let service: ProtestsService;
|
||||||
@@ -30,16 +34,21 @@ describe('ProtestsService', () => {
|
|||||||
decisionNotes: 'Notes',
|
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<ReviewProtestResult, ReviewProtestApplicationError>(coreResult));
|
||||||
executeMock.mockResolvedValue(Result.ok<void, never>(undefined));
|
|
||||||
|
|
||||||
const presenter = await service.reviewProtest(baseCommand);
|
const dto = await service.reviewProtest(baseCommand);
|
||||||
const viewModel = getViewModel(presenter as ReviewProtestPresenter);
|
|
||||||
|
|
||||||
expect(executeMock).toHaveBeenCalledWith(baseCommand);
|
expect(executeMock).toHaveBeenCalledWith(baseCommand);
|
||||||
expect(viewModel).toEqual({
|
expect(dto).toEqual<ReviewProtestResponseDTO>({
|
||||||
success: true,
|
success: true,
|
||||||
protestId: baseCommand.protestId,
|
protestId: baseCommand.protestId,
|
||||||
stewardId: baseCommand.stewardId,
|
stewardId: baseCommand.stewardId,
|
||||||
@@ -47,52 +56,69 @@ describe('ProtestsService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps PROTEST_NOT_FOUND error into presenter', async () => {
|
it('maps PROTEST_NOT_FOUND error into DTO', async () => {
|
||||||
executeMock.mockResolvedValue(Result.err({ code: 'PROTEST_NOT_FOUND' as const }));
|
const error: ReviewProtestApplicationError = {
|
||||||
|
code: 'PROTEST_NOT_FOUND',
|
||||||
|
details: { message: 'Protest not found' },
|
||||||
|
};
|
||||||
|
|
||||||
const presenter = await service.reviewProtest(baseCommand);
|
executeMock.mockResolvedValue(Result.err<ReviewProtestResult, ReviewProtestApplicationError>(error));
|
||||||
const viewModel = getViewModel(presenter as ReviewProtestPresenter);
|
|
||||||
|
|
||||||
expect(viewModel).toEqual({
|
const dto = await service.reviewProtest(baseCommand);
|
||||||
|
|
||||||
|
expect(dto).toEqual<ReviewProtestResponseDTO>({
|
||||||
success: false,
|
success: false,
|
||||||
errorCode: 'PROTEST_NOT_FOUND',
|
errorCode: 'PROTEST_NOT_FOUND',
|
||||||
message: 'Protest not found',
|
message: 'Protest not found',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps RACE_NOT_FOUND error into presenter', async () => {
|
it('maps RACE_NOT_FOUND error into DTO', async () => {
|
||||||
executeMock.mockResolvedValue(Result.err({ code: 'RACE_NOT_FOUND' as const }));
|
const error: ReviewProtestApplicationError = {
|
||||||
|
code: 'RACE_NOT_FOUND',
|
||||||
|
details: { message: 'Race not found for protest' },
|
||||||
|
};
|
||||||
|
|
||||||
const presenter = await service.reviewProtest(baseCommand);
|
executeMock.mockResolvedValue(Result.err<ReviewProtestResult, ReviewProtestApplicationError>(error));
|
||||||
const viewModel = getViewModel(presenter as ReviewProtestPresenter);
|
|
||||||
|
|
||||||
expect(viewModel).toEqual({
|
const dto = await service.reviewProtest(baseCommand);
|
||||||
|
|
||||||
|
expect(dto).toEqual<ReviewProtestResponseDTO>({
|
||||||
success: false,
|
success: false,
|
||||||
errorCode: 'RACE_NOT_FOUND',
|
errorCode: 'RACE_NOT_FOUND',
|
||||||
message: 'Race not found for protest',
|
message: 'Race not found for protest',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps NOT_LEAGUE_ADMIN error into presenter', async () => {
|
it('maps NOT_LEAGUE_ADMIN error into DTO', async () => {
|
||||||
executeMock.mockResolvedValue(Result.err({ code: 'NOT_LEAGUE_ADMIN' as const }));
|
const error: ReviewProtestApplicationError = {
|
||||||
|
code: 'NOT_LEAGUE_ADMIN',
|
||||||
|
details: { message: 'Steward is not authorized to review this protest' },
|
||||||
|
};
|
||||||
|
|
||||||
const presenter = await service.reviewProtest(baseCommand);
|
executeMock.mockResolvedValue(Result.err<ReviewProtestResult, ReviewProtestApplicationError>(error));
|
||||||
const viewModel = getViewModel(presenter as ReviewProtestPresenter);
|
|
||||||
|
|
||||||
expect(viewModel).toEqual({
|
const dto = await service.reviewProtest(baseCommand);
|
||||||
|
|
||||||
|
expect(dto).toEqual<ReviewProtestResponseDTO>({
|
||||||
success: false,
|
success: false,
|
||||||
errorCode: 'NOT_LEAGUE_ADMIN',
|
errorCode: 'NOT_LEAGUE_ADMIN',
|
||||||
message: 'Steward is not authorized to review this protest',
|
message: 'Steward is not authorized to review this protest',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps unexpected error code into generic failure', async () => {
|
it('maps unexpected error code into generic failure DTO', async () => {
|
||||||
executeMock.mockResolvedValue(Result.err({ code: 'UNEXPECTED' as unknown as never }));
|
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);
|
executeMock.mockResolvedValue(Result.err<ReviewProtestResult, ReviewProtestApplicationError>(error));
|
||||||
const viewModel = getViewModel(presenter as ReviewProtestPresenter);
|
|
||||||
|
|
||||||
expect(viewModel).toEqual({
|
const dto = await service.reviewProtest(baseCommand);
|
||||||
|
|
||||||
|
expect(dto).toEqual<ReviewProtestResponseDTO>({
|
||||||
success: false,
|
success: false,
|
||||||
errorCode: 'UNEXPECTED',
|
errorCode: 'UNEXPECTED',
|
||||||
message: 'Failed to review protest',
|
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';
|
import { ReviewProtestUseCase } from '@core/racing/application/use-cases/ReviewProtestUseCase';
|
||||||
|
|
||||||
// Presenter
|
// Presenter
|
||||||
import { ReviewProtestPresenter } from './presenters/ReviewProtestPresenter';
|
import { ReviewProtestPresenter, type ReviewProtestResponseDTO } from './presenters/ReviewProtestPresenter';
|
||||||
|
|
||||||
// Tokens
|
// Tokens
|
||||||
import { LOGGER_TOKEN } from './ProtestsProviders';
|
import { LOGGER_TOKEN } from './ProtestsProviders';
|
||||||
@@ -22,41 +22,14 @@ export class ProtestsService {
|
|||||||
stewardId: string;
|
stewardId: string;
|
||||||
decision: 'uphold' | 'dismiss';
|
decision: 'uphold' | 'dismiss';
|
||||||
decisionNotes: string;
|
decisionNotes: string;
|
||||||
}): Promise<ReviewProtestPresenter> {
|
}): Promise<ReviewProtestResponseDTO> {
|
||||||
this.logger.debug('[ProtestsService] Reviewing protest:', command);
|
this.logger.debug('[ProtestsService] Reviewing protest:', command);
|
||||||
|
|
||||||
const presenter = new ReviewProtestPresenter();
|
|
||||||
const result = await this.reviewProtestUseCase.execute(command);
|
const result = await this.reviewProtestUseCase.execute(command);
|
||||||
|
const presenter = new ReviewProtestPresenter();
|
||||||
|
|
||||||
if (result.isErr()) {
|
presenter.present(result);
|
||||||
const error = result.unwrapErr();
|
|
||||||
|
|
||||||
let message: string;
|
return presenter.responseModel;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
success: boolean;
|
||||||
errorCode?: string;
|
errorCode?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
@@ -8,38 +14,45 @@ export interface ReviewProtestViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ReviewProtestPresenter {
|
export class ReviewProtestPresenter {
|
||||||
private result: ReviewProtestViewModel | null = null;
|
private model: ReviewProtestResponseDTO | null = null;
|
||||||
|
|
||||||
reset(): void {
|
reset(): void {
|
||||||
this.result = null;
|
this.model = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
presentSuccess(payload: { protestId: string; stewardId: string; decision: 'uphold' | 'dismiss' }): void {
|
present(
|
||||||
this.result = {
|
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,
|
success: true,
|
||||||
protestId: payload.protestId,
|
protestId: value.protestId,
|
||||||
stewardId: payload.stewardId,
|
stewardId: value.stewardId,
|
||||||
decision: payload.decision,
|
decision: value.decision,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
presentError(errorCode: string, message?: string): void {
|
getResponseModel(): ReviewProtestResponseDTO | null {
|
||||||
this.result = {
|
return this.model;
|
||||||
success: false,
|
|
||||||
errorCode,
|
|
||||||
message,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewModel(): ReviewProtestViewModel | null {
|
get responseModel(): ReviewProtestResponseDTO {
|
||||||
return this.result;
|
if (!this.model) {
|
||||||
}
|
|
||||||
|
|
||||||
get viewModel(): ReviewProtestViewModel {
|
|
||||||
if (!this.result) {
|
|
||||||
throw new Error('Presenter not presented');
|
throw new Error('Presenter not presented');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.result;
|
return this.model;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,25 +96,20 @@ export class RaceService {
|
|||||||
async getAllRaces(): Promise<GetAllRacesPresenter> {
|
async getAllRaces(): Promise<GetAllRacesPresenter> {
|
||||||
this.logger.debug('[RaceService] Fetching all races.');
|
this.logger.debug('[RaceService] Fetching all races.');
|
||||||
|
|
||||||
const result = await this.getAllRacesUseCase.execute();
|
const result = await this.getAllRacesUseCase.execute({});
|
||||||
|
|
||||||
if (result.isErr()) {
|
|
||||||
throw new Error('Failed to get all races');
|
|
||||||
}
|
|
||||||
|
|
||||||
const presenter = new GetAllRacesPresenter();
|
const presenter = new GetAllRacesPresenter();
|
||||||
await presenter.present(result.unwrap());
|
presenter.reset();
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
return presenter;
|
return presenter;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTotalRaces(): Promise<GetTotalRacesPresenter> {
|
async getTotalRaces(): Promise<GetTotalRacesPresenter> {
|
||||||
this.logger.debug('[RaceService] Fetching total races count.');
|
this.logger.debug('[RaceService] Fetching total races count.');
|
||||||
const result = await this.getTotalRacesUseCase.execute();
|
const result = await this.getTotalRacesUseCase.execute({});
|
||||||
if (result.isErr()) {
|
|
||||||
throw new Error(result.unwrapErr().code);
|
|
||||||
}
|
|
||||||
const presenter = new GetTotalRacesPresenter();
|
const presenter = new GetTotalRacesPresenter();
|
||||||
presenter.present(result.unwrap());
|
presenter.present(result);
|
||||||
return presenter;
|
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';
|
import type { AllRacesPageDTO } from '../dtos/AllRacesPageDTO';
|
||||||
|
|
||||||
|
export type AllRacesPageDataResponseModel = AllRacesPageDTO;
|
||||||
|
|
||||||
|
export type GetAllRacesPageDataApplicationError = ApplicationErrorCode<
|
||||||
|
GetAllRacesPageDataErrorCode,
|
||||||
|
{ message: string }
|
||||||
|
>;
|
||||||
|
|
||||||
export class AllRacesPageDataPresenter {
|
export class AllRacesPageDataPresenter {
|
||||||
private result: AllRacesPageDTO | null = null;
|
private model: AllRacesPageDataResponseModel | null = null;
|
||||||
|
|
||||||
present(output: AllRacesPageDTO): void {
|
reset(): void {
|
||||||
this.result = output;
|
this.model = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewModel(): AllRacesPageDTO | null {
|
present(
|
||||||
return this.result;
|
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 {
|
getResponseModel(): AllRacesPageDataResponseModel | null {
|
||||||
if (!this.result) {
|
return this.model;
|
||||||
|
}
|
||||||
|
|
||||||
|
get responseModel(): AllRacesPageDataResponseModel {
|
||||||
|
if (!this.model) {
|
||||||
throw new Error('Presenter not presented');
|
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;
|
success: boolean;
|
||||||
errorCode?: string;
|
errorCode?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CommandResultPresenter {
|
export type CommandApplicationError<E extends string = string> = ApplicationErrorCode<
|
||||||
private result: CommandResultViewModel | null = null;
|
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 {
|
presentSuccess(message?: string): void {
|
||||||
this.result = {
|
this.model = {
|
||||||
success: true,
|
success: true,
|
||||||
message,
|
message,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
presentFailure(errorCode: string, message?: string): void {
|
presentFailure(errorCode: string, message?: string): void {
|
||||||
this.result = {
|
this.model = {
|
||||||
success: false,
|
success: false,
|
||||||
errorCode,
|
errorCode,
|
||||||
message,
|
message,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewModel(): CommandResultViewModel | null {
|
getResponseModel(): CommandResultDTO | null {
|
||||||
return this.result;
|
return this.model;
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewModel(): CommandResultViewModel {
|
get responseModel(): CommandResultDTO {
|
||||||
if (!this.result) {
|
if (!this.model) {
|
||||||
throw new Error('Presenter not presented');
|
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 { 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', () => {
|
describe('GetAllRacesPresenter', () => {
|
||||||
it('should map races and distinct leagues into the DTO', async () => {
|
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 type { Result } from '@core/shared/application/Result';
|
||||||
import { AllRacesPageDTO } from '../dtos/AllRacesPageDTO';
|
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 {
|
export class GetAllRacesPresenter {
|
||||||
private result: AllRacesPageDTO | null = null;
|
private model: GetAllRacesResponseModel | null = null;
|
||||||
|
|
||||||
reset() {
|
reset(): void {
|
||||||
this.result = null;
|
this.model = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async present(output: GetAllRacesOutputPort) {
|
present(result: Result<GetAllRacesResult, GetAllRacesApplicationError>): void {
|
||||||
const uniqueLeagues = new Map<string, { id: string; name: string }>();
|
if (result.isErr()) {
|
||||||
|
const error = result.unwrapErr();
|
||||||
for (const race of output.races) {
|
throw new Error(error.details?.message ?? 'Failed to get all races');
|
||||||
uniqueLeagues.set(race.leagueId, {
|
|
||||||
id: race.leagueId,
|
|
||||||
name: race.leagueName,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 => ({
|
races: output.races.map(race => ({
|
||||||
id: race.id,
|
id: race.id,
|
||||||
track: race.track,
|
track: race.track,
|
||||||
car: race.car,
|
car: race.car,
|
||||||
scheduledAt: race.scheduledAt,
|
scheduledAt: race.scheduledAt.toISOString(),
|
||||||
status: race.status,
|
status: race.status,
|
||||||
leagueId: race.leagueId,
|
leagueId: race.leagueId,
|
||||||
leagueName: race.leagueName,
|
leagueName: leagueMap.get(race.leagueId) ?? 'Unknown League',
|
||||||
strengthOfField: race.strengthOfField,
|
strengthOfField: race.strengthOfField ?? null,
|
||||||
})),
|
})),
|
||||||
filters: {
|
filters: {
|
||||||
statuses: [
|
statuses: [
|
||||||
@@ -42,7 +62,15 @@ export class GetAllRacesPresenter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewModel(): AllRacesPageDTO | null {
|
getResponseModel(): GetAllRacesResponseModel | null {
|
||||||
return this.result;
|
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 type { Result } from '@core/shared/application/Result';
|
||||||
import { RaceStatsDTO } from '../dtos/RaceStatsDTO';
|
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 {
|
export class GetTotalRacesPresenter {
|
||||||
private result: RaceStatsDTO | null = null;
|
private model: GetTotalRacesResponseModel | null = null;
|
||||||
|
|
||||||
reset() {
|
reset(): void {
|
||||||
this.result = null;
|
this.model = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
present(output: GetTotalRacesOutputPort) {
|
present(result: Result<GetTotalRacesResult, GetTotalRacesApplicationError>): void {
|
||||||
this.result = {
|
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,
|
totalRaces: output.totalRaces,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewModel(): RaceStatsDTO | null {
|
getResponseModel(): GetTotalRacesResponseModel | null {
|
||||||
return this.result;
|
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';
|
import { ImportRaceResultsSummaryDTO } from '../dtos/ImportRaceResultsSummaryDTO';
|
||||||
|
|
||||||
export class ImportRaceResultsApiPresenter {
|
export type ImportRaceResultsApiResponseModel = ImportRaceResultsSummaryDTO;
|
||||||
private result: ImportRaceResultsSummaryDTO | null = null;
|
|
||||||
|
|
||||||
reset() {
|
export type ImportRaceResultsApiApplicationError = ApplicationErrorCode<
|
||||||
this.result = null;
|
ImportRaceResultsApiErrorCode,
|
||||||
|
{ message: string }
|
||||||
|
>;
|
||||||
|
|
||||||
|
export class ImportRaceResultsApiPresenter {
|
||||||
|
private model: ImportRaceResultsApiResponseModel | null = null;
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.model = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
present(output: ImportRaceResultsApiOutputPort) {
|
present(
|
||||||
this.result = {
|
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,
|
success: output.success,
|
||||||
raceId: output.raceId,
|
raceId: output.raceId,
|
||||||
driversProcessed: output.driversProcessed,
|
driversProcessed: output.driversProcessed,
|
||||||
@@ -18,7 +39,15 @@ export class ImportRaceResultsApiPresenter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewModel(): ImportRaceResultsSummaryDTO | null {
|
getResponseModel(): ImportRaceResultsApiResponseModel | null {
|
||||||
return this.result;
|
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 { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider';
|
||||||
import type { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
|
import type { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
|
||||||
import type { GetRaceDetailParamsDTO } from '../dtos/GetRaceDetailParamsDTO';
|
import type { GetRaceDetailParamsDTO } from '../dtos/GetRaceDetailParamsDTO';
|
||||||
@@ -9,44 +14,79 @@ import type { RaceDetailEntryDTO } from '../dtos/RaceDetailEntryDTO';
|
|||||||
import type { RaceDetailRegistrationDTO } from '../dtos/RaceDetailRegistrationDTO';
|
import type { RaceDetailRegistrationDTO } from '../dtos/RaceDetailRegistrationDTO';
|
||||||
import type { RaceDetailUserResultDTO } from '../dtos/RaceDetailUserResultDTO';
|
import type { RaceDetailUserResultDTO } from '../dtos/RaceDetailUserResultDTO';
|
||||||
|
|
||||||
|
export type GetRaceDetailResponseModel = RaceDetailDTO;
|
||||||
|
|
||||||
|
export type GetRaceDetailApplicationError = ApplicationErrorCode<
|
||||||
|
GetRaceDetailErrorCode,
|
||||||
|
{ message: string }
|
||||||
|
>;
|
||||||
|
|
||||||
export class RaceDetailPresenter {
|
export class RaceDetailPresenter {
|
||||||
private result: RaceDetailDTO | null = null;
|
private model: GetRaceDetailResponseModel | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly driverRatingProvider: DriverRatingProvider,
|
private readonly driverRatingProvider: DriverRatingProvider,
|
||||||
private readonly imageService: IImageServicePort,
|
private readonly imageService: IImageServicePort,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async present(outputPort: RaceDetailOutputPort, params: GetRaceDetailParamsDTO): Promise<void> {
|
reset(): void {
|
||||||
const raceDTO: RaceDetailRaceDTO | null = outputPort.race
|
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,
|
id: output.race.id,
|
||||||
leagueId: outputPort.race.leagueId,
|
leagueId: output.race.leagueId,
|
||||||
track: outputPort.race.track,
|
track: output.race.track,
|
||||||
car: outputPort.race.car,
|
car: output.race.car,
|
||||||
scheduledAt: outputPort.race.scheduledAt.toISOString(),
|
scheduledAt: output.race.scheduledAt.toISOString(),
|
||||||
sessionType: outputPort.race.sessionType,
|
sessionType: output.race.sessionType,
|
||||||
status: outputPort.race.status,
|
status: output.race.status,
|
||||||
strengthOfField: outputPort.race.strengthOfField ?? null,
|
strengthOfField: output.race.strengthOfField ?? null,
|
||||||
registeredCount: outputPort.race.registeredCount ?? undefined,
|
registeredCount: output.race.registeredCount ?? undefined,
|
||||||
maxParticipants: outputPort.race.maxParticipants ?? undefined,
|
maxParticipants: output.race.maxParticipants ?? undefined,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const leagueDTO: RaceDetailLeagueDTO | null = outputPort.league
|
const leagueDTO: RaceDetailLeagueDTO | null = output.league
|
||||||
? {
|
? {
|
||||||
id: outputPort.league.id.toString(),
|
id: output.league.id.toString(),
|
||||||
name: outputPort.league.name.toString(),
|
name: output.league.name.toString(),
|
||||||
description: outputPort.league.description.toString(),
|
description: output.league.description.toString(),
|
||||||
settings: {
|
settings: {
|
||||||
maxDrivers: outputPort.league.settings.maxDrivers ?? undefined,
|
maxDrivers: output.league.settings.maxDrivers ?? undefined,
|
||||||
qualifyingFormat: outputPort.league.settings.qualifyingFormat ?? undefined,
|
qualifyingFormat: output.league.settings.qualifyingFormat ?? undefined,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const entryListDTO: RaceDetailEntryDTO[] = await Promise.all(
|
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 ratingResult = await this.driverRatingProvider.getDriverRating({ driverId: driver.id });
|
||||||
const avatarResult = await this.imageService.getDriverAvatar({ driverId: driver.id });
|
const avatarResult = await this.imageService.getDriverAvatar({ driverId: driver.id });
|
||||||
return {
|
return {
|
||||||
@@ -61,24 +101,24 @@ export class RaceDetailPresenter {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const registrationDTO: RaceDetailRegistrationDTO = {
|
const registrationDTO: RaceDetailRegistrationDTO = {
|
||||||
isUserRegistered: outputPort.isUserRegistered,
|
isUserRegistered: output.isUserRegistered,
|
||||||
canRegister: outputPort.canRegister,
|
canRegister: output.canRegister,
|
||||||
};
|
};
|
||||||
|
|
||||||
const userResultDTO: RaceDetailUserResultDTO | null = outputPort.userResult
|
const userResultDTO: RaceDetailUserResultDTO | null = output.userResult
|
||||||
? {
|
? {
|
||||||
position: outputPort.userResult.position.toNumber(),
|
position: output.userResult.position.toNumber(),
|
||||||
startPosition: outputPort.userResult.startPosition.toNumber(),
|
startPosition: output.userResult.startPosition.toNumber(),
|
||||||
incidents: outputPort.userResult.incidents.toNumber(),
|
incidents: output.userResult.incidents.toNumber(),
|
||||||
fastestLap: outputPort.userResult.fastestLap.toNumber(),
|
fastestLap: output.userResult.fastestLap.toNumber(),
|
||||||
positionChange: outputPort.userResult.getPositionChange(),
|
positionChange: output.userResult.getPositionChange(),
|
||||||
isPodium: outputPort.userResult.isPodium(),
|
isPodium: output.userResult.isPodium(),
|
||||||
isClean: outputPort.userResult.isClean(),
|
isClean: output.userResult.isClean(),
|
||||||
ratingChange: this.calculateRatingChange(outputPort.userResult.position.toNumber()),
|
ratingChange: this.calculateRatingChange(output.userResult.position.toNumber()),
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
this.result = {
|
this.model = {
|
||||||
race: raceDTO,
|
race: raceDTO,
|
||||||
league: leagueDTO,
|
league: leagueDTO,
|
||||||
entryList: entryListDTO,
|
entryList: entryListDTO,
|
||||||
@@ -87,16 +127,16 @@ export class RaceDetailPresenter {
|
|||||||
} as RaceDetailDTO;
|
} as RaceDetailDTO;
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewModel(): RaceDetailDTO | null {
|
getResponseModel(): GetRaceDetailResponseModel | null {
|
||||||
return this.result;
|
return this.model;
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewModel(): RaceDetailDTO {
|
get responseModel(): GetRaceDetailResponseModel {
|
||||||
if (!this.result) {
|
if (!this.model) {
|
||||||
throw new Error('Presenter not presented');
|
throw new Error('Presenter not presented');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.result;
|
return this.model;
|
||||||
}
|
}
|
||||||
|
|
||||||
private calculateRatingChange(position: number): number {
|
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 { RacePenaltiesDTO } from '../dtos/RacePenaltiesDTO';
|
||||||
import type { RacePenaltyDTO } from '../dtos/RacePenaltyDTO';
|
import type { RacePenaltyDTO } from '../dtos/RacePenaltyDTO';
|
||||||
|
|
||||||
export class RacePenaltiesPresenter {
|
export type GetRacePenaltiesResponseModel = RacePenaltiesDTO;
|
||||||
private result: RacePenaltiesDTO | null = null;
|
|
||||||
|
|
||||||
present(outputPort: RacePenaltiesOutputPort): void {
|
export type GetRacePenaltiesApplicationError = ApplicationErrorCode<
|
||||||
const penalties: RacePenaltyDTO[] = outputPort.penalties.map(penalty => ({
|
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,
|
id: penalty.id,
|
||||||
driverId: penalty.driverId,
|
driverId: penalty.driverId,
|
||||||
type: penalty.type,
|
type: penalty.type,
|
||||||
@@ -18,25 +41,25 @@ export class RacePenaltiesPresenter {
|
|||||||
} as RacePenaltyDTO));
|
} as RacePenaltyDTO));
|
||||||
|
|
||||||
const driverMap: Record<string, string> = {};
|
const driverMap: Record<string, string> = {};
|
||||||
outputPort.drivers.forEach(driver => {
|
output.drivers.forEach(driver => {
|
||||||
driverMap[driver.id] = driver.name.toString();
|
driverMap[driver.id] = driver.name.toString();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.result = {
|
this.model = {
|
||||||
penalties,
|
penalties,
|
||||||
driverMap,
|
driverMap,
|
||||||
} as RacePenaltiesDTO;
|
} as RacePenaltiesDTO;
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewModel(): RacePenaltiesDTO | null {
|
getResponseModel(): GetRacePenaltiesResponseModel | null {
|
||||||
return this.result;
|
return this.model;
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewModel(): RacePenaltiesDTO {
|
get responseModel(): GetRacePenaltiesResponseModel {
|
||||||
if (!this.result) {
|
if (!this.model) {
|
||||||
throw new Error('Presenter not presented');
|
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 { RaceProtestsDTO } from '../dtos/RaceProtestsDTO';
|
||||||
import type { RaceProtestDTO } from '../dtos/RaceProtestDTO';
|
import type { RaceProtestDTO } from '../dtos/RaceProtestDTO';
|
||||||
|
|
||||||
export class RaceProtestsPresenter {
|
export type GetRaceProtestsResponseModel = RaceProtestsDTO;
|
||||||
private result: RaceProtestsDTO | null = null;
|
|
||||||
|
|
||||||
present(outputPort: RaceProtestsOutputPort): void {
|
export type GetRaceProtestsApplicationError = ApplicationErrorCode<
|
||||||
const protests: RaceProtestDTO[] = outputPort.protests.map(protest => ({
|
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,
|
id: protest.id,
|
||||||
protestingDriverId: protest.protestingDriverId,
|
protestingDriverId: protest.protestingDriverId,
|
||||||
accusedDriverId: protest.accusedDriverId,
|
accusedDriverId: protest.accusedDriverId,
|
||||||
@@ -19,25 +42,25 @@ export class RaceProtestsPresenter {
|
|||||||
} as RaceProtestDTO));
|
} as RaceProtestDTO));
|
||||||
|
|
||||||
const driverMap: Record<string, string> = {};
|
const driverMap: Record<string, string> = {};
|
||||||
outputPort.drivers.forEach(driver => {
|
output.drivers.forEach(driver => {
|
||||||
driverMap[driver.id] = driver.name.toString();
|
driverMap[driver.id] = driver.name.toString();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.result = {
|
this.model = {
|
||||||
protests,
|
protests,
|
||||||
driverMap,
|
driverMap,
|
||||||
} as RaceProtestsDTO;
|
} as RaceProtestsDTO;
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewModel(): RaceProtestsDTO | null {
|
getResponseModel(): GetRaceProtestsResponseModel | null {
|
||||||
return this.result;
|
return this.model;
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewModel(): RaceProtestsDTO {
|
get responseModel(): GetRaceProtestsResponseModel {
|
||||||
if (!this.result) {
|
if (!this.model) {
|
||||||
throw new Error('Presenter not presented');
|
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 { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
|
||||||
import type { RaceResultsDetailDTO } from '../dtos/RaceResultsDetailDTO';
|
import type { RaceResultsDetailDTO } from '../dtos/RaceResultsDetailDTO';
|
||||||
import type { RaceResultDTO } from '../dtos/RaceResultDTO';
|
import type { RaceResultDTO } from '../dtos/RaceResultDTO';
|
||||||
|
|
||||||
|
export type GetRaceResultsDetailResponseModel = RaceResultsDetailDTO;
|
||||||
|
|
||||||
|
export type GetRaceResultsDetailApplicationError = ApplicationErrorCode<
|
||||||
|
GetRaceResultsDetailErrorCode,
|
||||||
|
{ message: string }
|
||||||
|
>;
|
||||||
|
|
||||||
export class RaceResultsDetailPresenter {
|
export class RaceResultsDetailPresenter {
|
||||||
private result: RaceResultsDetailDTO | null = null;
|
private model: GetRaceResultsDetailResponseModel | null = null;
|
||||||
|
|
||||||
constructor(private readonly imageService: IImageServicePort) {}
|
constructor(private readonly imageService: IImageServicePort) {}
|
||||||
|
|
||||||
async present(outputPort: RaceResultsDetailOutputPort): Promise<void> {
|
reset(): void {
|
||||||
const driverMap = new Map(outputPort.drivers.map(driver => [driver.id, driver]));
|
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(
|
const results: RaceResultDTO[] = await Promise.all(
|
||||||
outputPort.results.map(async singleResult => {
|
output.results.map(async singleResult => {
|
||||||
const driver = driverMap.get(singleResult.driverId.toString());
|
const driver = driverMap.get(singleResult.driverId.toString());
|
||||||
if (!driver) {
|
if (!driver) {
|
||||||
throw new Error(`Driver not found for result: ${singleResult.driverId}`);
|
throw new Error(`Driver not found for result: ${singleResult.driverId}`);
|
||||||
@@ -35,22 +70,22 @@ export class RaceResultsDetailPresenter {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.result = {
|
this.model = {
|
||||||
raceId: outputPort.race.id,
|
raceId: output.race.id,
|
||||||
track: outputPort.race.track,
|
track: output.race.track,
|
||||||
results,
|
results,
|
||||||
} as RaceResultsDetailDTO;
|
} as RaceResultsDetailDTO;
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewModel(): RaceResultsDetailDTO | null {
|
getResponseModel(): GetRaceResultsDetailResponseModel | null {
|
||||||
return this.result;
|
return this.model;
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewModel(): RaceResultsDetailDTO {
|
get responseModel(): GetRaceResultsDetailResponseModel {
|
||||||
if (!this.result) {
|
if (!this.model) {
|
||||||
throw new Error('Presenter not presented');
|
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';
|
import type { RaceWithSOFDTO } from '../dtos/RaceWithSOFDTO';
|
||||||
|
|
||||||
export class RaceWithSOFPresenter {
|
export type GetRaceWithSOFResponseModel = RaceWithSOFDTO;
|
||||||
private result: RaceWithSOFDTO | null = null;
|
|
||||||
|
|
||||||
present(outputPort: RaceWithSOFOutputPort): void {
|
export type GetRaceWithSOFApplicationError = ApplicationErrorCode<
|
||||||
this.result = {
|
GetRaceWithSOFErrorCode,
|
||||||
id: outputPort.id,
|
{ message: string }
|
||||||
track: outputPort.track,
|
>;
|
||||||
strengthOfField: outputPort.strengthOfField,
|
|
||||||
|
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;
|
} as RaceWithSOFDTO;
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewModel(): RaceWithSOFDTO | null {
|
getResponseModel(): GetRaceWithSOFResponseModel | null {
|
||||||
return this.result;
|
return this.model;
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewModel(): RaceWithSOFDTO {
|
get responseModel(): GetRaceWithSOFResponseModel {
|
||||||
if (!this.result) {
|
if (!this.model) {
|
||||||
throw new Error('Presenter not presented');
|
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 { Result } from '@core/shared/application/Result';
|
||||||
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
|
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 { RacesPageDataDTO } from '../dtos/RacesPageDataDTO';
|
||||||
import type { RacesPageDataRaceDTO } from '../dtos/RacesPageDataRaceDTO';
|
import type { RacesPageDataRaceDTO } from '../dtos/RacesPageDataRaceDTO';
|
||||||
|
|
||||||
|
export type GetRacesPageDataResponseModel = RacesPageDataDTO;
|
||||||
|
|
||||||
|
export type GetRacesPageDataApplicationError = ApplicationErrorCode<
|
||||||
|
GetRacesPageDataErrorCode,
|
||||||
|
{ message: string }
|
||||||
|
>;
|
||||||
|
|
||||||
export class RacesPageDataPresenter {
|
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> {
|
present(
|
||||||
const allLeagues = await this.leagueRepository.findAll();
|
result: Result<GetRacesPageDataResult, GetRacesPageDataApplicationError>,
|
||||||
const leagueMap = new Map(allLeagues.map(l => [l.id, l.name]));
|
): 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,
|
id: race.id,
|
||||||
track: race.track,
|
track: race.track,
|
||||||
car: race.car,
|
car: race.car,
|
||||||
scheduledAt: race.scheduledAt.toISOString(),
|
scheduledAt: race.scheduledAt.toISOString(),
|
||||||
status: race.status,
|
status: race.status,
|
||||||
leagueId: race.leagueId,
|
leagueId: race.leagueId,
|
||||||
leagueName: leagueMap.get(race.leagueId) ?? 'Unknown League',
|
leagueName,
|
||||||
strengthOfField: race.strengthOfField,
|
strengthOfField: race.strengthOfField ?? null,
|
||||||
isUpcoming: race.scheduledAt > new Date(),
|
isUpcoming: race.scheduledAt > new Date(),
|
||||||
isLive: race.status === 'running',
|
isLive: race.status === 'running',
|
||||||
isPast: race.scheduledAt < new Date() && race.status === 'completed',
|
isPast: race.scheduledAt < new Date() && race.status === 'completed',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.result = { races } as RacesPageDataDTO;
|
this.model = { races } as RacesPageDataDTO;
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewModel(): RacesPageDataDTO | null {
|
getResponseModel(): GetRacesPageDataResponseModel | null {
|
||||||
return this.result;
|
return this.model;
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewModel(): RacesPageDataDTO {
|
get responseModel(): GetRacesPageDataResponseModel {
|
||||||
if (!this.result) {
|
if (!this.model) {
|
||||||
throw new Error('Presenter not presented');
|
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 { ISponsorshipRequestRepository } from '@core/racing/domain/repositories/ISponsorshipRequestRepository';
|
||||||
import type { Logger } from '@core/shared/application';
|
import type { Logger } from '@core/shared/application';
|
||||||
|
|
||||||
// Import use cases / application services
|
import { GetSponsorBillingUseCase } from '@core/payments/application/use-cases/GetSponsorBillingUseCase';
|
||||||
import { SponsorBillingService } from '@core/payments/application/services/SponsorBillingService';
|
|
||||||
import { AcceptSponsorshipRequestUseCase } from '@core/racing/application/use-cases/AcceptSponsorshipRequestUseCase';
|
import { AcceptSponsorshipRequestUseCase } from '@core/racing/application/use-cases/AcceptSponsorshipRequestUseCase';
|
||||||
import { CreateSponsorUseCase } from '@core/racing/application/use-cases/CreateSponsorUseCase';
|
import { CreateSponsorUseCase } from '@core/racing/application/use-cases/CreateSponsorUseCase';
|
||||||
import { GetEntitySponsorshipPricingUseCase } from '@core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase';
|
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 GET_PENDING_SPONSORSHIP_REQUESTS_USE_CASE_TOKEN = 'GetPendingSponsorshipRequestsUseCase';
|
||||||
export const ACCEPT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN = 'AcceptSponsorshipRequestUseCase';
|
export const ACCEPT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN = 'AcceptSponsorshipRequestUseCase';
|
||||||
export const REJECT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN = 'RejectSponsorshipRequestUseCase';
|
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[] = [
|
export const SponsorProviders: Provider[] = [
|
||||||
SponsorService,
|
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) =>
|
useFactory: (paymentRepo: IPaymentRepository, seasonSponsorshipRepo: ISeasonSponsorshipRepository) =>
|
||||||
new SponsorBillingService(paymentRepo, seasonSponsorshipRepo),
|
new GetSponsorBillingUseCase(paymentRepo, seasonSponsorshipRepo),
|
||||||
inject: ['IPaymentRepository', SEASON_SPONSORSHIP_REPOSITORY_TOKEN],
|
inject: ['IPaymentRepository', SEASON_SPONSORSHIP_REPOSITORY_TOKEN],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import {
|
|||||||
} from '@core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
|
} from '@core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
|
||||||
import { AcceptSponsorshipRequestUseCase } from '@core/racing/application/use-cases/AcceptSponsorshipRequestUseCase';
|
import { AcceptSponsorshipRequestUseCase } from '@core/racing/application/use-cases/AcceptSponsorshipRequestUseCase';
|
||||||
import { RejectSponsorshipRequestUseCase } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
|
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 { SponsorableEntityType } from '@core/racing/domain/entities/SponsorshipRequest';
|
||||||
import type { Logger } from '@core/shared/application';
|
import type { Logger } from '@core/shared/application';
|
||||||
|
|
||||||
@@ -82,6 +84,8 @@ export class SponsorService {
|
|||||||
private readonly acceptSponsorshipRequestUseCase: AcceptSponsorshipRequestUseCase,
|
private readonly acceptSponsorshipRequestUseCase: AcceptSponsorshipRequestUseCase,
|
||||||
@Inject(REJECT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN)
|
@Inject(REJECT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN)
|
||||||
private readonly rejectSponsorshipRequestUseCase: RejectSponsorshipRequestUseCase,
|
private readonly rejectSponsorshipRequestUseCase: RejectSponsorshipRequestUseCase,
|
||||||
|
@Inject(GET_SPONSOR_BILLING_USE_CASE_TOKEN)
|
||||||
|
private readonly getSponsorBillingUseCase: GetSponsorBillingUseCase,
|
||||||
@Inject(LOGGER_TOKEN)
|
@Inject(LOGGER_TOKEN)
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
) {}
|
) {}
|
||||||
@@ -102,20 +106,15 @@ export class SponsorService {
|
|||||||
return presenter;
|
return presenter;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSponsors(): Promise<GetSponsorsPresenter> {
|
async getSponsors(): Promise<GetSponsorsOutputDTO> {
|
||||||
this.logger.debug('[SponsorService] Fetching sponsors.');
|
this.logger.debug('[SponsorService] Fetching sponsors.');
|
||||||
|
|
||||||
const presenter = new GetSponsorsPresenter();
|
const presenter = new GetSponsorsPresenter();
|
||||||
const result = await this.getSponsorsUseCase.execute();
|
const result = await this.getSponsorsUseCase.execute();
|
||||||
|
|
||||||
if (result.isErr()) {
|
presenter.present(result);
|
||||||
this.logger.error('[SponsorService] Failed to fetch sponsors.', result.error);
|
|
||||||
presenter.present({ sponsors: [] });
|
|
||||||
return presenter;
|
|
||||||
}
|
|
||||||
|
|
||||||
presenter.present(result.value);
|
return presenter.responseModel;
|
||||||
return presenter;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSponsor(input: CreateSponsorInputDTO): Promise<CreateSponsorPresenter> {
|
async createSponsor(input: CreateSponsorInputDTO): Promise<CreateSponsorPresenter> {
|
||||||
@@ -264,92 +263,18 @@ export class SponsorService {
|
|||||||
return presenter;
|
return presenter;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSponsorBilling(sponsorId: string): Promise<SponsorBillingPresenter> {
|
async getSponsorBilling(sponsorId: string): Promise<GetSponsorBillingPresenter> {
|
||||||
this.logger.debug('[SponsorService] Fetching sponsor billing.', { sponsorId });
|
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
|
if (result.isErr()) {
|
||||||
const paymentMethods: PaymentMethodDTO[] = [
|
this.logger.error('[SponsorService] Failed to fetch sponsor billing.', result.error);
|
||||||
{
|
throw new Error(result.error.details?.message || 'Failed to fetch sponsor billing');
|
||||||
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,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const invoices: InvoiceDTO[] = [
|
const presenter = new GetSponsorBillingPresenter();
|
||||||
{
|
presenter.present(result.value);
|
||||||
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 });
|
|
||||||
return presenter;
|
return presenter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
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';
|
import { GetSponsorsPresenter } from './GetSponsorsPresenter';
|
||||||
|
|
||||||
describe('GetSponsorsPresenter', () => {
|
describe('GetSponsorsPresenter', () => {
|
||||||
@@ -9,54 +15,92 @@ describe('GetSponsorsPresenter', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('reset', () => {
|
describe('reset', () => {
|
||||||
it('should reset the result to null', () => {
|
it('should reset the model to null and cause responseModel to throw', () => {
|
||||||
const mockResult = { sponsors: [] };
|
const result = Result.ok<GetSponsorsResult, never>({ sponsors: [] });
|
||||||
presenter.present(mockResult);
|
presenter.present(result);
|
||||||
expect(presenter.viewModel).toEqual(mockResult);
|
expect(presenter.responseModel).toEqual({ sponsors: [] });
|
||||||
|
|
||||||
presenter.reset();
|
presenter.reset();
|
||||||
expect(() => presenter.viewModel).toThrow('Presenter not presented');
|
expect(() => presenter.responseModel).toThrow('Presenter not presented');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('present', () => {
|
describe('present', () => {
|
||||||
it('should store the result', () => {
|
it('should map Result.ok sponsors to DTO responseModel', () => {
|
||||||
const mockResult = {
|
const result = Result.ok<GetSponsorsResult, never>({
|
||||||
sponsors: [
|
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', () => {
|
it('should return null when not presented', () => {
|
||||||
expect(presenter.getViewModel()).toBeNull();
|
expect(presenter.getResponseModel()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return the result when presented', () => {
|
it('should return the model when presented', () => {
|
||||||
const mockResult = { sponsors: [] };
|
const result = Result.ok<GetSponsorsResult, never>({ sponsors: [] });
|
||||||
presenter.present(mockResult);
|
presenter.present(result);
|
||||||
|
|
||||||
expect(presenter.getViewModel()).toEqual(mockResult);
|
expect(presenter.getResponseModel()).toEqual({ sponsors: [] });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('viewModel', () => {
|
describe('responseModel', () => {
|
||||||
it('should throw error when not presented', () => {
|
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', () => {
|
it('should fallback to empty sponsors list on error', () => {
|
||||||
const mockResult = { sponsors: [] };
|
const error = {
|
||||||
presenter.present(mockResult);
|
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 { GetSponsorsOutputDTO } from '../dtos/GetSponsorsOutputDTO';
|
||||||
|
import type { SponsorDTO } from '../dtos/SponsorDTO';
|
||||||
|
|
||||||
export class GetSponsorsPresenter {
|
export class GetSponsorsPresenter {
|
||||||
private result: GetSponsorsOutputDTO | null = null;
|
private model: GetSponsorsOutputDTO | null = null;
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
this.result = null;
|
this.model = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
present(outputPort: GetSponsorsOutputPort) {
|
present(
|
||||||
this.result = {
|
result: Result<
|
||||||
sponsors: outputPort.sponsors,
|
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 {
|
getResponseModel(): GetSponsorsOutputDTO | null {
|
||||||
return this.result;
|
return this.model;
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewModel(): GetSponsorsOutputDTO {
|
get responseModel(): GetSponsorsOutputDTO {
|
||||||
if (!this.result) throw new Error('Presenter not presented');
|
if (!this.model) throw new Error('Presenter not presented');
|
||||||
return this.result;
|
return this.model;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export class TeamController {
|
|||||||
@ApiResponse({ status: 200, description: 'List of all teams', type: GetAllTeamsOutputDTO })
|
@ApiResponse({ status: 200, description: 'List of all teams', type: GetAllTeamsOutputDTO })
|
||||||
async getAll(): Promise<GetAllTeamsOutputDTO> {
|
async getAll(): Promise<GetAllTeamsOutputDTO> {
|
||||||
const presenter = await this.teamService.getAll();
|
const presenter = await this.teamService.getAll();
|
||||||
return presenter.viewModel;
|
return presenter.responseModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':teamId')
|
@Get(':teamId')
|
||||||
@@ -33,7 +33,7 @@ export class TeamController {
|
|||||||
async getDetails(@Param('teamId') teamId: string, @Req() req: Request): Promise<GetTeamDetailsOutputDTO | null> {
|
async getDetails(@Param('teamId') teamId: string, @Req() req: Request): Promise<GetTeamDetailsOutputDTO | null> {
|
||||||
const userId = req['user']?.userId;
|
const userId = req['user']?.userId;
|
||||||
const presenter = await this.teamService.getDetails(teamId, userId);
|
const presenter = await this.teamService.getDetails(teamId, userId);
|
||||||
return presenter.getViewModel();
|
return presenter.getResponseModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':teamId/members')
|
@Get(':teamId/members')
|
||||||
@@ -41,7 +41,7 @@ export class TeamController {
|
|||||||
@ApiResponse({ status: 200, description: 'Team members', type: GetTeamMembersOutputDTO })
|
@ApiResponse({ status: 200, description: 'Team members', type: GetTeamMembersOutputDTO })
|
||||||
async getMembers(@Param('teamId') teamId: string): Promise<GetTeamMembersOutputDTO> {
|
async getMembers(@Param('teamId') teamId: string): Promise<GetTeamMembersOutputDTO> {
|
||||||
const presenter = await this.teamService.getMembers(teamId);
|
const presenter = await this.teamService.getMembers(teamId);
|
||||||
return presenter.getViewModel()!;
|
return presenter.getResponseModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':teamId/join-requests')
|
@Get(':teamId/join-requests')
|
||||||
@@ -49,7 +49,7 @@ export class TeamController {
|
|||||||
@ApiResponse({ status: 200, description: 'Team join requests', type: GetTeamJoinRequestsOutputDTO })
|
@ApiResponse({ status: 200, description: 'Team join requests', type: GetTeamJoinRequestsOutputDTO })
|
||||||
async getJoinRequests(@Param('teamId') teamId: string): Promise<GetTeamJoinRequestsOutputDTO> {
|
async getJoinRequests(@Param('teamId') teamId: string): Promise<GetTeamJoinRequestsOutputDTO> {
|
||||||
const presenter = await this.teamService.getJoinRequests(teamId);
|
const presenter = await this.teamService.getJoinRequests(teamId);
|
||||||
return presenter.getViewModel()!;
|
return presenter.getResponseModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -58,7 +58,7 @@ export class TeamController {
|
|||||||
async create(@Body() input: CreateTeamInputDTO, @Req() req: Request): Promise<CreateTeamOutputDTO> {
|
async create(@Body() input: CreateTeamInputDTO, @Req() req: Request): Promise<CreateTeamOutputDTO> {
|
||||||
const userId = req['user']?.userId;
|
const userId = req['user']?.userId;
|
||||||
const presenter = await this.teamService.create(input, userId);
|
const presenter = await this.teamService.create(input, userId);
|
||||||
return presenter.viewModel;
|
return presenter.responseModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':teamId')
|
@Patch(':teamId')
|
||||||
@@ -67,7 +67,7 @@ export class TeamController {
|
|||||||
async update(@Param('teamId') teamId: string, @Body() input: UpdateTeamInputDTO, @Req() req: Request): Promise<UpdateTeamOutputDTO> {
|
async update(@Param('teamId') teamId: string, @Body() input: UpdateTeamInputDTO, @Req() req: Request): Promise<UpdateTeamOutputDTO> {
|
||||||
const userId = req['user']?.userId;
|
const userId = req['user']?.userId;
|
||||||
const presenter = await this.teamService.update(teamId, input, userId);
|
const presenter = await this.teamService.update(teamId, input, userId);
|
||||||
return presenter.viewModel;
|
return presenter.responseModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('driver/:driverId')
|
@Get('driver/:driverId')
|
||||||
@@ -76,15 +76,15 @@ export class TeamController {
|
|||||||
@ApiResponse({ status: 404, description: 'Team not found' })
|
@ApiResponse({ status: 404, description: 'Team not found' })
|
||||||
async getDriverTeam(@Param('driverId') driverId: string): Promise<GetDriverTeamOutputDTO | null> {
|
async getDriverTeam(@Param('driverId') driverId: string): Promise<GetDriverTeamOutputDTO | null> {
|
||||||
const presenter = await this.teamService.getDriverTeam(driverId);
|
const presenter = await this.teamService.getDriverTeam(driverId);
|
||||||
return presenter.getViewModel();
|
return presenter.getResponseModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':teamId/members/:driverId')
|
@Get(':teamId/members/:driverId')
|
||||||
@ApiOperation({ summary: 'Get team membership for a driver' })
|
@ApiOperation({ summary: 'Get team membership for a driver' })
|
||||||
@ApiResponse({ status: 200, description: 'Team membership', type: GetTeamMembershipOutputDTO })
|
@ApiResponse({ status: 200, description: 'Team membership', type: GetTeamMembershipOutputDTO })
|
||||||
@ApiResponse({ status: 404, description: 'Membership not found' })
|
@ApiResponse({ status: 404, description: 'Membership not found' })
|
||||||
async getMembership(@Param('teamId') teamId: string, @Param('driverId') driverId: string): Promise<GetTeamMembershipOutputDTO | null> {
|
async getMembership(@Param('teamId') teamId: string, @Param('driverId') driverId: string): Promise<GetTeamMembershipOutputDTO | null> {
|
||||||
const presenter = await this.teamService.getMembership(teamId, driverId);
|
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 { Injectable, Inject } from '@nestjs/common';
|
||||||
import { CreateTeamInputDTO } from './dtos/CreateTeamInputDTO';
|
import { CreateTeamInputDTO } from './dtos/CreateTeamInputDTO';
|
||||||
import { UpdateTeamInputDTO } from './dtos/UpdateTeamInputDTO';
|
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
|
// Core imports
|
||||||
import type { Logger } from '@core/shared/application/Logger';
|
import type { Logger } from '@core/shared/application/Logger';
|
||||||
@@ -42,19 +50,19 @@ export class TeamService {
|
|||||||
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
|
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getAll(): Promise<AllTeamsPresenter> {
|
async getAll(): Promise<any> { // TODO: type
|
||||||
this.logger.debug('[TeamService] Fetching all teams.');
|
this.logger.debug('[TeamService] Fetching all teams.');
|
||||||
|
|
||||||
const presenter = new AllTeamsPresenter();
|
|
||||||
const result = await this.getAllTeamsUseCase.execute();
|
const result = await this.getAllTeamsUseCase.execute();
|
||||||
|
const presenter = new AllTeamsPresenter();
|
||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
this.logger.error('Error fetching all teams', result.error);
|
this.logger.error('Error fetching all teams', result.error);
|
||||||
await presenter.present({ teams: [], totalCount: 0 });
|
await presenter.present({ teams: [], totalCount: 0 });
|
||||||
return presenter;
|
return presenter.responseModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
await presenter.present(result.value);
|
await presenter.present(result.value);
|
||||||
return presenter;
|
return presenter.responseModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDetails(teamId: string, userId?: string): Promise<TeamDetailsPresenter> {
|
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';
|
import { GetAllTeamsOutputDTO } from '../dtos/GetAllTeamsOutputDTO';
|
||||||
|
|
||||||
export class AllTeamsPresenter {
|
export type GetAllTeamsError = ApplicationErrorCode<GetAllTeamsErrorCode, { message: string }>;
|
||||||
private result: GetAllTeamsOutputDTO | null = null;
|
|
||||||
|
|
||||||
reset() {
|
export class AllTeamsPresenter {
|
||||||
this.result = null;
|
private model: GetAllTeamsOutputDTO | null = null;
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.model = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async present(output: GetAllTeamsOutputPort) {
|
present(result: Result<GetAllTeamsResult, GetAllTeamsError>): void {
|
||||||
this.result = {
|
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 => ({
|
teams: output.teams.map(team => ({
|
||||||
id: team.id,
|
id: team.id,
|
||||||
name: team.name,
|
name: team.name,
|
||||||
@@ -17,18 +28,18 @@ export class AllTeamsPresenter {
|
|||||||
description: team.description,
|
description: team.description,
|
||||||
memberCount: team.memberCount,
|
memberCount: team.memberCount,
|
||||||
leagues: team.leagues || [],
|
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 {
|
getResponseModel(): GetAllTeamsOutputDTO | null {
|
||||||
return this.result;
|
return this.model;
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewModel(): GetAllTeamsOutputDTO {
|
get responseModel(): GetAllTeamsOutputDTO {
|
||||||
if (!this.result) throw new Error('Presenter not presented');
|
if (!this.model) throw new Error('Presenter not presented');
|
||||||
return this.result;
|
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';
|
import type { CreateTeamOutputDTO } from '../dtos/CreateTeamOutputDTO';
|
||||||
|
|
||||||
|
export type CreateTeamError = ApplicationErrorCode<CreateTeamErrorCode, { message: string }>;
|
||||||
|
|
||||||
export class CreateTeamPresenter {
|
export class CreateTeamPresenter {
|
||||||
private result: CreateTeamOutputDTO | null = null;
|
private model: CreateTeamOutputDTO | null = null;
|
||||||
|
|
||||||
reset(): void {
|
reset(): void {
|
||||||
this.result = null;
|
this.model = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
presentSuccess(output: CreateTeamOutputPort): void {
|
present(result: Result<CreateTeamResult, CreateTeamError>): void {
|
||||||
this.result = {
|
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,
|
id: output.team.id,
|
||||||
success: true,
|
success: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
presentError(): void {
|
getResponseModel(): CreateTeamOutputDTO | null {
|
||||||
this.result = {
|
return this.model;
|
||||||
id: '',
|
|
||||||
success: false,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewModel(): CreateTeamOutputDTO | null {
|
get responseModel(): CreateTeamOutputDTO {
|
||||||
return this.result;
|
if (!this.model) {
|
||||||
}
|
|
||||||
|
|
||||||
get viewModel(): CreateTeamOutputDTO {
|
|
||||||
if (!this.result) {
|
|
||||||
throw new Error('Presenter not presented');
|
throw new Error('Presenter not presented');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.result;
|
return this.model;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ export class HelloController {
|
|||||||
@Get()
|
@Get()
|
||||||
getHello(): { message: string } {
|
getHello(): { message: string } {
|
||||||
const presenter = this.helloService.getHello();
|
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 { 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 { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
|
||||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||||
import { Result } from '@core/shared/application/Result';
|
import { Result } from '@core/shared/application/Result';
|
||||||
@@ -16,7 +16,7 @@ describe('GetAnalyticsMetricsUseCase', () => {
|
|||||||
getBounceRate: Mock;
|
getBounceRate: Mock;
|
||||||
};
|
};
|
||||||
let logger: Logger;
|
let logger: Logger;
|
||||||
let output: UseCaseOutputPort<Result<any, any>> & { present: Mock };
|
let output: UseCaseOutputPort<GetAnalyticsMetricsOutput> & { present: Mock };
|
||||||
let useCase: GetAnalyticsMetricsUseCase;
|
let useCase: GetAnalyticsMetricsUseCase;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -44,14 +44,21 @@ describe('GetAnalyticsMetricsUseCase', () => {
|
|||||||
|
|
||||||
useCase = new GetAnalyticsMetricsUseCase(
|
useCase = new GetAnalyticsMetricsUseCase(
|
||||||
pageViewRepository as unknown as IPageViewRepository,
|
pageViewRepository as unknown as IPageViewRepository,
|
||||||
output,
|
|
||||||
logger,
|
logger,
|
||||||
|
output,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('presents default metrics and logs retrieval when no input is provided', async () => {
|
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();
|
expect((logger.info as unknown as Mock)).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,8 +73,9 @@ describe('GetAnalyticsMetricsUseCase', () => {
|
|||||||
throw new Error('Logging failed');
|
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();
|
expect((logger.error as unknown as Mock)).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Logger, UseCaseOutputPort, UseCase } from '@core/shared/application';
|
import type { Logger, UseCase, UseCaseOutputPort } from '@core/shared/application';
|
||||||
import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
|
|
||||||
import { Result } from '@core/shared/application/Result';
|
import { Result } from '@core/shared/application/Result';
|
||||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||||
|
import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
|
||||||
|
|
||||||
export interface GetAnalyticsMetricsInput {
|
export interface GetAnalyticsMetricsInput {
|
||||||
startDate?: Date;
|
startDate?: Date;
|
||||||
@@ -17,25 +17,33 @@ export interface GetAnalyticsMetricsOutput {
|
|||||||
|
|
||||||
export type GetAnalyticsMetricsErrorCode = 'REPOSITORY_ERROR';
|
export type GetAnalyticsMetricsErrorCode = 'REPOSITORY_ERROR';
|
||||||
|
|
||||||
export class GetAnalyticsMetricsUseCase implements UseCase<GetAnalyticsMetricsInput, GetAnalyticsMetricsOutput, GetAnalyticsMetricsErrorCode> {
|
export class GetAnalyticsMetricsUseCase implements UseCase<GetAnalyticsMetricsInput, void, GetAnalyticsMetricsErrorCode> {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly pageViewRepository: IPageViewRepository,
|
private readonly pageViewRepository: IPageViewRepository,
|
||||||
private readonly logger: Logger,
|
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 {
|
try {
|
||||||
const startDate = input.startDate ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days ago
|
const startDate = input.startDate ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days ago
|
||||||
const endDate = input.endDate ?? new Date();
|
const endDate = input.endDate ?? new Date();
|
||||||
|
|
||||||
// For now, return placeholder values as actual implementation would require
|
// TODO static data
|
||||||
// aggregating data across all entities or specifying which entity
|
|
||||||
// This is a simplified version
|
|
||||||
const pageViews = 0;
|
const pageViews = 0;
|
||||||
const uniqueVisitors = 0;
|
const uniqueVisitors = 0;
|
||||||
const averageSessionDuration = 0;
|
const averageSessionDuration = 0;
|
||||||
const bounceRate = 0;
|
const bounceRate = 0;
|
||||||
|
|
||||||
|
const resultModel: GetAnalyticsMetricsOutput = {
|
||||||
|
pageViews,
|
||||||
|
uniqueVisitors,
|
||||||
|
averageSessionDuration,
|
||||||
|
bounceRate,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.output.present(resultModel);
|
||||||
|
|
||||||
this.logger.info('Analytics metrics retrieved', {
|
this.logger.info('Analytics metrics retrieved', {
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
@@ -43,21 +51,14 @@ export class GetAnalyticsMetricsUseCase implements UseCase<GetAnalyticsMetricsIn
|
|||||||
uniqueVisitors,
|
uniqueVisitors,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = Result.ok<GetAnalyticsMetricsOutput, ApplicationErrorCode<GetAnalyticsMetricsErrorCode, { message: string }>>({
|
return Result.ok(undefined);
|
||||||
pageViews,
|
|
||||||
uniqueVisitors,
|
|
||||||
averageSessionDuration,
|
|
||||||
bounceRate,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
this.logger.error('Failed to get analytics metrics', err, { input });
|
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',
|
code: 'REPOSITORY_ERROR',
|
||||||
details: { message: err.message ?? 'Failed to get analytics metrics' },
|
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 { 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 type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||||
import { Result } from '@core/shared/application/Result';
|
import { Result } from '@core/shared/application/Result';
|
||||||
|
|
||||||
describe('GetDashboardDataUseCase', () => {
|
describe('GetDashboardDataUseCase', () => {
|
||||||
let logger: Logger;
|
let logger: Logger;
|
||||||
let output: UseCaseOutputPort<Result<any, any>> & { present: Mock };
|
let output: UseCaseOutputPort<GetDashboardDataOutput> & { present: Mock };
|
||||||
let useCase: GetDashboardDataUseCase;
|
let useCase: GetDashboardDataUseCase;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -20,18 +20,19 @@ describe('GetDashboardDataUseCase', () => {
|
|||||||
present: vi.fn(),
|
present: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
useCase = new GetDashboardDataUseCase(output, logger);
|
useCase = new GetDashboardDataUseCase(logger, output);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('presents placeholder dashboard metrics and logs retrieval', async () => {
|
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,
|
totalUsers: 0,
|
||||||
activeUsers: 0,
|
activeUsers: 0,
|
||||||
totalRaces: 0,
|
totalRaces: 0,
|
||||||
totalLeagues: 0,
|
totalLeagues: 0,
|
||||||
}));
|
});
|
||||||
|
|
||||||
expect((logger.info as unknown as Mock)).toHaveBeenCalled();
|
expect((logger.info as unknown as Mock)).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ export interface GetDashboardDataOutput {
|
|||||||
|
|
||||||
export type GetDashboardDataErrorCode = 'REPOSITORY_ERROR';
|
export type GetDashboardDataErrorCode = 'REPOSITORY_ERROR';
|
||||||
|
|
||||||
export class GetDashboardDataUseCase implements UseCase<GetDashboardDataInput, GetDashboardDataOutput, GetDashboardDataErrorCode> {
|
export class GetDashboardDataUseCase implements UseCase<GetDashboardDataInput, void, GetDashboardDataErrorCode> {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly logger: Logger,
|
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 {
|
try {
|
||||||
// Placeholder implementation - would need repositories from identity and racing domains
|
// Placeholder implementation - would need repositories from identity and racing domains
|
||||||
const totalUsers = 0;
|
const totalUsers = 0;
|
||||||
@@ -26,6 +27,15 @@ export class GetDashboardDataUseCase implements UseCase<GetDashboardDataInput, G
|
|||||||
const totalRaces = 0;
|
const totalRaces = 0;
|
||||||
const totalLeagues = 0;
|
const totalLeagues = 0;
|
||||||
|
|
||||||
|
const resultModel: GetDashboardDataOutput = {
|
||||||
|
totalUsers,
|
||||||
|
activeUsers,
|
||||||
|
totalRaces,
|
||||||
|
totalLeagues,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.output.present(resultModel);
|
||||||
|
|
||||||
this.logger.info('Dashboard data retrieved', {
|
this.logger.info('Dashboard data retrieved', {
|
||||||
totalUsers,
|
totalUsers,
|
||||||
activeUsers,
|
activeUsers,
|
||||||
@@ -33,21 +43,14 @@ export class GetDashboardDataUseCase implements UseCase<GetDashboardDataInput, G
|
|||||||
totalLeagues,
|
totalLeagues,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = Result.ok<GetDashboardDataOutput, ApplicationErrorCode<GetDashboardDataErrorCode, { message: string }>>({
|
return Result.ok(undefined);
|
||||||
totalUsers,
|
|
||||||
activeUsers,
|
|
||||||
totalRaces,
|
|
||||||
totalLeagues,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
this.logger.error('Failed to get dashboard data', err);
|
this.logger.error('Failed to get dashboard data', err);
|
||||||
const result = Result.err<GetDashboardDataOutput, ApplicationErrorCode<GetDashboardDataErrorCode, { message: string }>>({
|
return Result.err({
|
||||||
code: 'REPOSITORY_ERROR',
|
code: 'REPOSITORY_ERROR',
|
||||||
details: { message: err.message ?? 'Failed to get dashboard data' },
|
details: { message: err.message ?? 'Failed to get dashboard data' },
|
||||||
});
|
});
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,20 +66,22 @@ describe('GetEntityAnalyticsQuery', () => {
|
|||||||
|
|
||||||
const result = await useCase.execute(input);
|
const result = await useCase.execute(input);
|
||||||
|
|
||||||
expect(result.entityId).toBe(input.entityId);
|
expect(result.isOk()).toBe(true);
|
||||||
expect(result.entityType).toBe(input.entityType);
|
const data = result.unwrap();
|
||||||
|
expect(data.entityId).toBe(input.entityId);
|
||||||
|
expect(data.entityType).toBe(input.entityType);
|
||||||
|
|
||||||
expect(result.summary.totalPageViews).toBe(100);
|
expect(data.summary.totalPageViews).toBe(100);
|
||||||
expect(result.summary.uniqueVisitors).toBe(40);
|
expect(data.summary.uniqueVisitors).toBe(40);
|
||||||
expect(result.summary.sponsorClicks).toBe(10);
|
expect(data.summary.sponsorClicks).toBe(10);
|
||||||
expect(typeof result.summary.engagementScore).toBe('number');
|
expect(typeof data.summary.engagementScore).toBe('number');
|
||||||
expect(result.summary.exposureValue).toBeGreaterThan(0);
|
expect(data.summary.exposureValue).toBeGreaterThan(0);
|
||||||
|
|
||||||
expect(result.trends.pageViewsChange).toBeDefined();
|
expect(data.trends.pageViewsChange).toBeDefined();
|
||||||
expect(result.trends.uniqueVisitorsChange).toBeDefined();
|
expect(data.trends.uniqueVisitorsChange).toBeDefined();
|
||||||
|
|
||||||
expect(result.period.start).toBeInstanceOf(Date);
|
expect(data.period.start).toBeInstanceOf(Date);
|
||||||
expect(result.period.end).toBeInstanceOf(Date);
|
expect(data.period.end).toBeInstanceOf(Date);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('propagates repository errors', async () => {
|
it('propagates repository errors', async () => {
|
||||||
@@ -90,7 +92,9 @@ describe('GetEntityAnalyticsQuery', () => {
|
|||||||
|
|
||||||
pageViewRepository.countByEntityId.mockRejectedValue(new Error('DB error'));
|
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();
|
expect((logger.error as unknown as Mock)).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ export class GetEntityAnalyticsQuery
|
|||||||
private readonly pageViewRepository: IPageViewRepository,
|
private readonly pageViewRepository: IPageViewRepository,
|
||||||
private readonly engagementRepository: IEngagementRepository,
|
private readonly engagementRepository: IEngagementRepository,
|
||||||
private readonly snapshotRepository: IAnalyticsSnapshotRepository,
|
private readonly snapshotRepository: IAnalyticsSnapshotRepository,
|
||||||
private readonly output: UseCaseOutputPort<Result<EntityAnalyticsOutput, ApplicationErrorCode<GetEntityAnalyticsErrorCode, { message: string }>>>,
|
|
||||||
private readonly logger: Logger
|
private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -145,10 +144,8 @@ export class GetEntityAnalyticsQuery
|
|||||||
label: this.formatPeriodLabel(since, now),
|
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}.`);
|
this.logger.info(`Successfully retrieved analytics for entity ${input.entityId}.`);
|
||||||
return result;
|
return Result.ok(resultData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
this.logger.error(`Failed to get entity analytics for ${input.entityId}: ${err.message}`, err);
|
this.logger.error(`Failed to get entity analytics for ${input.entityId}: ${err.message}`, err);
|
||||||
@@ -156,7 +153,6 @@ export class GetEntityAnalyticsQuery
|
|||||||
code: 'REPOSITORY_ERROR',
|
code: 'REPOSITORY_ERROR',
|
||||||
details: { message: err.message ?? 'Failed to get entity analytics' },
|
details: { message: err.message ?? 'Failed to get entity analytics' },
|
||||||
});
|
});
|
||||||
this.output.present(result);
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
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 type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository';
|
||||||
import { EngagementEvent } from '../../domain/entities/EngagementEvent';
|
import { EngagementEvent } from '../../domain/entities/EngagementEvent';
|
||||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||||
@@ -11,7 +11,7 @@ describe('RecordEngagementUseCase', () => {
|
|||||||
save: Mock;
|
save: Mock;
|
||||||
};
|
};
|
||||||
let logger: Logger;
|
let logger: Logger;
|
||||||
let output: UseCaseOutputPort<Result<any, any>> & { present: Mock };
|
let output: UseCaseOutputPort<RecordEngagementOutput> & { present: Mock };
|
||||||
let useCase: RecordEngagementUseCase;
|
let useCase: RecordEngagementUseCase;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -32,8 +32,8 @@ describe('RecordEngagementUseCase', () => {
|
|||||||
|
|
||||||
useCase = new RecordEngagementUseCase(
|
useCase = new RecordEngagementUseCase(
|
||||||
engagementRepository as unknown as IEngagementRepository,
|
engagementRepository as unknown as IEngagementRepository,
|
||||||
output,
|
|
||||||
logger,
|
logger,
|
||||||
|
output,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,8 +50,9 @@ describe('RecordEngagementUseCase', () => {
|
|||||||
|
|
||||||
engagementRepository.save.mockResolvedValue(undefined);
|
engagementRepository.save.mockResolvedValue(undefined);
|
||||||
|
|
||||||
await useCase.execute(input);
|
const result = await useCase.execute(input);
|
||||||
|
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
expect(engagementRepository.save).toHaveBeenCalledTimes(1);
|
expect(engagementRepository.save).toHaveBeenCalledTimes(1);
|
||||||
const saved = (engagementRepository.save as unknown as Mock).mock.calls[0][0] as EngagementEvent;
|
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.entityId).toBe(input.entityId);
|
||||||
expect(saved.entityType).toBe(input.entityType);
|
expect(saved.entityType).toBe(input.entityType);
|
||||||
|
|
||||||
|
expect(output.present).toHaveBeenCalledWith({
|
||||||
|
eventId: saved.id,
|
||||||
|
engagementWeight: saved.getEngagementWeight(),
|
||||||
|
});
|
||||||
expect((logger.info as unknown as Mock)).toHaveBeenCalled();
|
expect((logger.info as unknown as Mock)).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,8 +80,9 @@ describe('RecordEngagementUseCase', () => {
|
|||||||
const error = new Error('DB error');
|
const error = new Error('DB error');
|
||||||
engagementRepository.save.mockRejectedValue(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();
|
expect((logger.error as unknown as Mock)).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,13 +22,14 @@ export interface RecordEngagementOutput {
|
|||||||
|
|
||||||
export type RecordEngagementErrorCode = 'REPOSITORY_ERROR';
|
export type RecordEngagementErrorCode = 'REPOSITORY_ERROR';
|
||||||
|
|
||||||
export class RecordEngagementUseCase implements UseCase<RecordEngagementInput, RecordEngagementOutput, RecordEngagementErrorCode> {
|
export class RecordEngagementUseCase implements UseCase<RecordEngagementInput, void, RecordEngagementErrorCode> {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly engagementRepository: IEngagementRepository,
|
private readonly engagementRepository: IEngagementRepository,
|
||||||
private readonly logger: Logger,
|
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 {
|
try {
|
||||||
const engagementEvent = EngagementEvent.create({
|
const engagementEvent = EngagementEvent.create({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
@@ -43,6 +44,13 @@ export class RecordEngagementUseCase implements UseCase<RecordEngagementInput, R
|
|||||||
|
|
||||||
await this.engagementRepository.save(engagementEvent);
|
await this.engagementRepository.save(engagementEvent);
|
||||||
|
|
||||||
|
const resultModel: RecordEngagementOutput = {
|
||||||
|
eventId: engagementEvent.id,
|
||||||
|
engagementWeight: engagementEvent.getEngagementWeight(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.output.present(resultModel);
|
||||||
|
|
||||||
this.logger.info('Engagement event recorded', {
|
this.logger.info('Engagement event recorded', {
|
||||||
engagementId: engagementEvent.id,
|
engagementId: engagementEvent.id,
|
||||||
action: input.action,
|
action: input.action,
|
||||||
@@ -50,19 +58,14 @@ export class RecordEngagementUseCase implements UseCase<RecordEngagementInput, R
|
|||||||
entityType: input.entityType,
|
entityType: input.entityType,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = Result.ok<RecordEngagementOutput, ApplicationErrorCode<RecordEngagementErrorCode, { message: string }>>({
|
return Result.ok(undefined);
|
||||||
eventId: engagementEvent.id,
|
|
||||||
engagementWeight: engagementEvent.getEngagementWeight(),
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
this.logger.error('Failed to record engagement event', err, { input });
|
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',
|
code: 'REPOSITORY_ERROR',
|
||||||
details: { message: err.message ?? 'Failed to record engagement event' },
|
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 { 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 type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
|
||||||
import { PageView } from '../../domain/entities/PageView';
|
import { PageView } from '../../domain/entities/PageView';
|
||||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||||
@@ -11,7 +11,7 @@ describe('RecordPageViewUseCase', () => {
|
|||||||
save: Mock;
|
save: Mock;
|
||||||
};
|
};
|
||||||
let logger: Logger;
|
let logger: Logger;
|
||||||
let output: UseCaseOutputPort<Result<any, any>> & { present: Mock };
|
let output: UseCaseOutputPort<RecordPageViewOutput> & { present: Mock };
|
||||||
let useCase: RecordPageViewUseCase;
|
let useCase: RecordPageViewUseCase;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -32,8 +32,8 @@ describe('RecordPageViewUseCase', () => {
|
|||||||
|
|
||||||
useCase = new RecordPageViewUseCase(
|
useCase = new RecordPageViewUseCase(
|
||||||
pageViewRepository as unknown as IPageViewRepository,
|
pageViewRepository as unknown as IPageViewRepository,
|
||||||
output,
|
|
||||||
logger,
|
logger,
|
||||||
|
output,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -51,8 +51,9 @@ describe('RecordPageViewUseCase', () => {
|
|||||||
|
|
||||||
pageViewRepository.save.mockResolvedValue(undefined);
|
pageViewRepository.save.mockResolvedValue(undefined);
|
||||||
|
|
||||||
await useCase.execute(input);
|
const result = await useCase.execute(input);
|
||||||
|
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
expect(pageViewRepository.save).toHaveBeenCalledTimes(1);
|
expect(pageViewRepository.save).toHaveBeenCalledTimes(1);
|
||||||
const saved = (pageViewRepository.save as unknown as Mock).mock.calls[0][0] as PageView;
|
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.entityId).toBe(input.entityId);
|
||||||
expect(saved.entityType).toBe(input.entityType);
|
expect(saved.entityType).toBe(input.entityType);
|
||||||
|
|
||||||
|
expect(output.present).toHaveBeenCalledWith({
|
||||||
|
pageViewId: saved.id,
|
||||||
|
});
|
||||||
expect((logger.info as unknown as Mock)).toHaveBeenCalled();
|
expect((logger.info as unknown as Mock)).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,8 +79,9 @@ describe('RecordPageViewUseCase', () => {
|
|||||||
const error = new Error('DB error');
|
const error = new Error('DB error');
|
||||||
pageViewRepository.save.mockRejectedValue(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();
|
expect((logger.error as unknown as Mock)).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,13 +22,14 @@ export interface RecordPageViewOutput {
|
|||||||
|
|
||||||
export type RecordPageViewErrorCode = 'REPOSITORY_ERROR';
|
export type RecordPageViewErrorCode = 'REPOSITORY_ERROR';
|
||||||
|
|
||||||
export class RecordPageViewUseCase implements UseCase<RecordPageViewInput, RecordPageViewOutput, RecordPageViewErrorCode> {
|
export class RecordPageViewUseCase implements UseCase<RecordPageViewInput, void, RecordPageViewErrorCode> {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly pageViewRepository: IPageViewRepository,
|
private readonly pageViewRepository: IPageViewRepository,
|
||||||
private readonly logger: Logger,
|
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 {
|
try {
|
||||||
const pageView = PageView.create({
|
const pageView = PageView.create({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
@@ -44,24 +45,26 @@ export class RecordPageViewUseCase implements UseCase<RecordPageViewInput, Recor
|
|||||||
|
|
||||||
await this.pageViewRepository.save(pageView);
|
await this.pageViewRepository.save(pageView);
|
||||||
|
|
||||||
|
const resultModel: RecordPageViewOutput = {
|
||||||
|
pageViewId: pageView.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.output.present(resultModel);
|
||||||
|
|
||||||
this.logger.info('Page view recorded', {
|
this.logger.info('Page view recorded', {
|
||||||
pageViewId: pageView.id,
|
pageViewId: pageView.id,
|
||||||
entityId: input.entityId,
|
entityId: input.entityId,
|
||||||
entityType: input.entityType,
|
entityType: input.entityType,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = Result.ok<RecordPageViewOutput, ApplicationErrorCode<RecordPageViewErrorCode, { message: string }>>({
|
return Result.ok(undefined);
|
||||||
pageViewId: pageView.id,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
this.logger.error('Failed to record page view', err, { input });
|
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',
|
code: 'REPOSITORY_ERROR',
|
||||||
details: { message: err.message ?? 'Failed to record page view' },
|
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.
|
* Handles user login by verifying credentials.
|
||||||
*/
|
*/
|
||||||
export class LoginUseCase implements UseCase<LoginInput, LoginResult, LoginErrorCode> {
|
export class LoginUseCase implements UseCase<LoginInput, void, LoginErrorCode> {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly authRepo: IAuthRepository,
|
private readonly authRepo: IAuthRepository,
|
||||||
private readonly passwordService: IPasswordHashingService,
|
private readonly passwordService: IPasswordHashingService,
|
||||||
private readonly logger: Logger,
|
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 {
|
try {
|
||||||
const emailVO = EmailAddress.create(input.email);
|
const emailVO = EmailAddress.create(input.email);
|
||||||
const user = await this.authRepo.findByEmail(emailVO);
|
const user = await this.authRepo.findByEmail(emailVO);
|
||||||
|
|
||||||
if (!user || !user.getPasswordHash()) {
|
if (!user || !user.getPasswordHash()) {
|
||||||
const result = Result.err<LoginResult, LoginApplicationError>({
|
return Result.err({
|
||||||
code: 'INVALID_CREDENTIALS',
|
code: 'INVALID_CREDENTIALS',
|
||||||
details: { message: 'Invalid credentials' },
|
details: { message: 'Invalid credentials' },
|
||||||
});
|
});
|
||||||
this.output.present(result);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = user.getPasswordHash()!;
|
const passwordHash = user.getPasswordHash()!;
|
||||||
const isValid = await this.passwordService.verify(input.password, passwordHash.value);
|
const isValid = await this.passwordService.verify(input.password, passwordHash.value);
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
const result = Result.err<LoginResult, LoginApplicationError>({
|
return Result.err<LoginApplicationError>({
|
||||||
code: 'INVALID_CREDENTIALS',
|
code: 'INVALID_CREDENTIALS',
|
||||||
details: { message: 'Invalid credentials' },
|
details: { message: 'Invalid credentials' },
|
||||||
});
|
});
|
||||||
this.output.present(result);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = Result.ok<LoginResult, LoginApplicationError>({ user });
|
this.output.present({ user });
|
||||||
this.output.present(result);
|
return Result.ok(undefined);
|
||||||
return result;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error && error.message
|
error instanceof Error && error.message
|
||||||
@@ -71,12 +66,10 @@ export class LoginUseCase implements UseCase<LoginInput, LoginResult, LoginError
|
|||||||
input,
|
input,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = Result.err<LoginResult, LoginApplicationError>({
|
return Result.err<LoginApplicationError>({
|
||||||
code: 'REPOSITORY_ERROR',
|
code: 'REPOSITORY_ERROR',
|
||||||
details: { message },
|
details: { message },
|
||||||
});
|
});
|
||||||
this.output.present(result);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,26 +26,24 @@ export type SignupApplicationError = ApplicationErrorCode<SignupErrorCode, { mes
|
|||||||
*
|
*
|
||||||
* Handles user registration.
|
* Handles user registration.
|
||||||
*/
|
*/
|
||||||
export class SignupUseCase implements UseCase<SignupInput, SignupResult, SignupErrorCode> {
|
export class SignupUseCase implements UseCase<SignupInput, void, SignupErrorCode> {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly authRepo: IAuthRepository,
|
private readonly authRepo: IAuthRepository,
|
||||||
private readonly passwordService: IPasswordHashingService,
|
private readonly passwordService: IPasswordHashingService,
|
||||||
private readonly logger: Logger,
|
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 {
|
try {
|
||||||
const emailVO = EmailAddress.create(input.email);
|
const emailVO = EmailAddress.create(input.email);
|
||||||
|
|
||||||
const existingUser = await this.authRepo.findByEmail(emailVO);
|
const existingUser = await this.authRepo.findByEmail(emailVO);
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
const result = Result.err<SignupResult, SignupApplicationError>({
|
return Result.err({
|
||||||
code: 'USER_ALREADY_EXISTS',
|
code: 'USER_ALREADY_EXISTS',
|
||||||
details: { message: 'User already exists' },
|
details: { message: 'User already exists' },
|
||||||
});
|
});
|
||||||
this.output.present(result);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashedPassword = await this.passwordService.hash(input.password);
|
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);
|
await this.authRepo.save(user);
|
||||||
|
|
||||||
const result = Result.ok<SignupResult, SignupApplicationError>({ user });
|
this.output.present({ user });
|
||||||
this.output.present(result);
|
return Result.ok(undefined);
|
||||||
return result;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error && error.message
|
error instanceof Error && error.message
|
||||||
@@ -75,12 +72,10 @@ export class SignupUseCase implements UseCase<SignupInput, SignupResult, SignupE
|
|||||||
input,
|
input,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = Result.err<SignupResult, SignupApplicationError>({
|
return Result.err({
|
||||||
code: 'REPOSITORY_ERROR',
|
code: 'REPOSITORY_ERROR',
|
||||||
details: { message },
|
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 { Driver } from '../../domain/entities/Driver';
|
||||||
import { Result } from '@core/shared/application/Result';
|
import { Result } from '@core/shared/application/Result';
|
||||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
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';
|
import type { Logger } from '@core/shared/application/Logger';
|
||||||
|
|
||||||
export interface CompleteDriverOnboardingInput {
|
export interface CompleteDriverOnboardingInput {
|
||||||
@@ -30,7 +30,7 @@ export type CompleteDriverOnboardingApplicationError = ApplicationErrorCode<
|
|||||||
/**
|
/**
|
||||||
* Use Case for completing driver onboarding.
|
* Use Case for completing driver onboarding.
|
||||||
*/
|
*/
|
||||||
export class CompleteDriverOnboardingUseCase {
|
export class CompleteDriverOnboardingUseCase implements UseCase<CompleteDriverOnboardingInput, void, CompleteDriverOnboardingErrorCode> {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly driverRepository: IDriverRepository,
|
private readonly driverRepository: IDriverRepository,
|
||||||
private readonly logger: Logger,
|
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 type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
|
||||||
import { Result } from '@core/shared/application/Result';
|
import { Result } from '@core/shared/application/Result';
|
||||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||||
|
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||||
import { League } from '../../domain/entities/League';
|
import { League } from '../../domain/entities/League';
|
||||||
import { Race } from '../../domain/entities/Race';
|
import { Race } from '../../domain/entities/Race';
|
||||||
import { Result as RaceResult } from '../../domain/entities/Result';
|
import { Result as RaceResult } from '../../domain/entities/Result';
|
||||||
@@ -96,13 +97,14 @@ export class DashboardOverviewUseCase {
|
|||||||
private readonly getDriverStats: (
|
private readonly getDriverStats: (
|
||||||
driverId: string,
|
driverId: string,
|
||||||
) => DashboardDriverStatsAdapter | null,
|
) => DashboardDriverStatsAdapter | null,
|
||||||
|
private readonly output: UseCaseOutputPort<DashboardOverviewResult>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(
|
async execute(
|
||||||
input: DashboardOverviewInput,
|
input: DashboardOverviewInput,
|
||||||
): Promise<
|
): Promise<
|
||||||
Result<
|
Result<
|
||||||
DashboardOverviewResult,
|
void,
|
||||||
ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }>
|
ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }>
|
||||||
>
|
>
|
||||||
> {
|
> {
|
||||||
@@ -207,7 +209,9 @@ export class DashboardOverviewUseCase {
|
|||||||
friends: friendsSummary,
|
friends: friendsSummary,
|
||||||
};
|
};
|
||||||
|
|
||||||
return Result.ok(result);
|
this.output.present(result);
|
||||||
|
|
||||||
|
return Result.ok(undefined);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return Result.err({
|
return Result.err({
|
||||||
code: 'REPOSITORY_ERROR',
|
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 type { Logger } from '@core/shared/application';
|
||||||
import { Result } from '@core/shared/application/Result';
|
import { Result } from '@core/shared/application/Result';
|
||||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
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 { Driver } from '../../domain/entities/Driver';
|
||||||
import type { Team } from '../../domain/entities/Team';
|
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 = {
|
export type GetDriversLeaderboardInput = {
|
||||||
leagueId: string;
|
leagueId?: string;
|
||||||
seasonId?: string;
|
seasonId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,11 +33,14 @@ export interface GetDriversLeaderboardResult {
|
|||||||
activeCount: number;
|
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.
|
* Use Case for retrieving driver leaderboard data.
|
||||||
* Orchestrates domain logic and returns result.
|
* Returns a Result containing the domain leaderboard model.
|
||||||
*/
|
*/
|
||||||
export class GetDriversLeaderboardUseCase {
|
export class GetDriversLeaderboardUseCase {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -47,13 +49,18 @@ export class GetDriversLeaderboardUseCase {
|
|||||||
private readonly driverStatsService: IDriverStatsService,
|
private readonly driverStatsService: IDriverStatsService,
|
||||||
private readonly getDriverAvatar: (driverId: string) => Promise<string | undefined>,
|
private readonly getDriverAvatar: (driverId: string) => Promise<string | undefined>,
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
private readonly output: UseCaseOutputPort<GetDriversLeaderboardResult>,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(
|
async execute(
|
||||||
_input: GetDriversLeaderboardInput,
|
input: GetDriversLeaderboardInput,
|
||||||
): Promise<Result<GetDriversLeaderboardResult, ApplicationErrorCode<GetDriversLeaderboardErrorCode, { message: string }>>> {
|
): Promise<
|
||||||
this.logger.debug('Executing GetDriversLeaderboardUseCase');
|
Result<
|
||||||
|
GetDriversLeaderboardResult,
|
||||||
|
ApplicationErrorCode<GetDriversLeaderboardErrorCode, { message: string }>
|
||||||
|
>
|
||||||
|
> {
|
||||||
|
this.logger.debug('Executing GetDriversLeaderboardUseCase', { input });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const drivers = await this.driverRepository.findAll();
|
const drivers = await this.driverRepository.findAll();
|
||||||
const rankings = this.rankingService.getAllDriverRankings();
|
const rankings = this.rankingService.getAllDriverRankings();
|
||||||
@@ -64,12 +71,15 @@ export class GetDriversLeaderboardUseCase {
|
|||||||
avatarUrls[driver.id] = await this.getDriverAvatar(driver.id);
|
avatarUrls[driver.id] = await this.getDriverAvatar(driver.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const items: DriverLeaderboardItem[] = drivers.map((driver) => {
|
// TODO maps way too much data, should just create Domain Objects
|
||||||
const ranking = rankings.find((r) => r.driverId === driver.id);
|
|
||||||
|
const items: DriverLeaderboardItem[] = drivers.map(driver => {
|
||||||
|
const ranking = rankings.find(r => r.driverId === driver.id);
|
||||||
const stats = this.driverStatsService.getDriverStats(driver.id);
|
const stats = this.driverStatsService.getDriverStats(driver.id);
|
||||||
const rating = ranking?.rating ?? 0;
|
const rating = ranking?.rating ?? 0;
|
||||||
const racesCompleted = stats?.totalRaces ?? 0;
|
const racesCompleted = stats?.totalRaces ?? 0;
|
||||||
const skillLevel: SkillLevel = SkillLevelService.getSkillLevel(rating);
|
const skillLevel: SkillLevel = SkillLevelService.getSkillLevel(rating);
|
||||||
|
const avatarUrl = avatarUrls[driver.id];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
driver,
|
driver,
|
||||||
@@ -80,30 +90,32 @@ export class GetDriversLeaderboardUseCase {
|
|||||||
podiums: stats?.podiums ?? 0,
|
podiums: stats?.podiums ?? 0,
|
||||||
isActive: racesCompleted > 0,
|
isActive: racesCompleted > 0,
|
||||||
rank: ranking?.overallRank ?? 0,
|
rank: ranking?.overallRank ?? 0,
|
||||||
avatarUrl: avatarUrls[driver.id],
|
...(avatarUrl !== undefined ? { avatarUrl } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalRaces = items.reduce((sum, d) => sum + d.racesCompleted, 0);
|
const totalRaces = items.reduce((sum, d) => sum + d.racesCompleted, 0);
|
||||||
const totalWins = items.reduce((sum, d) => sum + d.wins, 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.');
|
const result: GetDriversLeaderboardResult = {
|
||||||
|
|
||||||
return Result.ok({
|
|
||||||
items: items.sort((a, b) => b.rating - a.rating),
|
items: items.sort((a, b) => b.rating - a.rating),
|
||||||
totalRaces,
|
totalRaces,
|
||||||
totalWins,
|
totalWins,
|
||||||
activeCount,
|
activeCount,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
this.logger.debug('Successfully computed drivers leaderboard');
|
||||||
|
|
||||||
|
return Result.ok(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
'Error executing GetDriversLeaderboardUseCase',
|
|
||||||
error instanceof Error ? error : new Error(String(error)),
|
this.logger.error('Error executing GetDriversLeaderboardUseCase', err);
|
||||||
);
|
|
||||||
return Result.err({
|
return Result.err({
|
||||||
code: 'REPOSITORY_ERROR',
|
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 type { TeamMembership } from '../../domain/types/TeamMembership';
|
||||||
import { Result } from '@core/shared/application/Result';
|
import { Result } from '@core/shared/application/Result';
|
||||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
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 {
|
interface ProfileDriverStatsAdapter {
|
||||||
rating: number | null;
|
rating: number | null;
|
||||||
@@ -92,7 +92,7 @@ export type GetProfileOverviewErrorCode =
|
|||||||
| 'DRIVER_NOT_FOUND'
|
| 'DRIVER_NOT_FOUND'
|
||||||
| 'REPOSITORY_ERROR';
|
| 'REPOSITORY_ERROR';
|
||||||
|
|
||||||
export class GetProfileOverviewUseCase {
|
export class GetProfileOverviewUseCase implements UseCase<GetProfileOverviewInput, void, GetProfileOverviewErrorCode> {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly driverRepository: IDriverRepository,
|
private readonly driverRepository: IDriverRepository,
|
||||||
private readonly teamRepository: ITeamRepository,
|
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